# Plugins — full documentation > Single-file Markdown export for building plugins with skills, MCP servers, and optional UI. Curated index: https://developers.openai.com/plugins/llms.txt # Plugin guidelines These guidelines cover the MCP server and optional UI in a plugin. For the complete submission flow, including skills, portal steps, review, approval, and publishing, see [Submit plugins](https://developers.openai.com/plugins/deploy/submission). ## Overview The plugin ecosystem is built on trust. People come to ChatGPT and Codex expecting experiences that are safe, useful, and respectful of their privacy. Developers expect a fair and transparent process. These developer guidelines set the policies every builder is expected to review and follow. Before getting into specifics, review the [optional UI guidelines](https://developers.openai.com/plugins/concepts/ui-guidelines) for interaction, layout, and design patterns that help plugin UI feel intuitive, trustworthy, and consistent within ChatGPT. You can also read the principles in [what makes a great experience in ChatGPT](https://developers.openai.com/blog/what-makes-a-great-chatgpt-app/). The guidelines below outline the minimum standard a published plugin must meet to remain available in the universal directory shared by ChatGPT and Codex. Plugins that demonstrate strong real-world utility and high user satisfaction may be eligible for enhanced distribution opportunities, such as directory placement or proactive suggestions. ## Plugin fundamentals ### Purpose and originality Plugins should serve a clear purpose and reliably do what they promise. In particular, they should provide functionality or workflows that are not natively supported by the products' built-in capabilities and that meaningfully help satisfy common user intents expressed in conversation. Only use intellectual property that you own or have permission to use. Do not engage in misleading or copycat designs, impersonation, spam, or static frames with no meaningful interaction. Plugins should not imply that they are made or endorsed by OpenAI. ### Quality and reliability Plugins must behave predictably and reliably. Results should be accurate and relevant to user input. Errors, including unexpected ones, must be handled with clear messaging or fallback behaviors. Before submitting a plugin, thoroughly test its MCP server, tools, and optional UI across a wide range of scenarios. Plugins should be stable, responsive, and complete. Trial or demo plugins will not be accepted. ### Plugin name, description, and optional screenshots Plugin names and descriptions must be clear, accurate, and straightforward. Avoid overly generic names, especially single-word dictionary terms that aren't explicitly tied to your brand. Screenshots are optional for plugins with UI. Don't submit screenshots for plugins without UI. If you include screenshots, they must accurately represent the plugin's functionality and comply with the required dimensions. ### Tools MCP tools tell ChatGPT and Codex how to use your server's capabilities. Clear, accurate tool definitions make the plugin safer, easier for the model to understand, and easier for users to trust. #### Clear and accurate tool names Tool names should be human-readable, specific, and descriptive of what the tool actually does. - Tool names must be unique within your MCP server. - Use plain language that directly reflects the action, ideally as a verb (for example, `get_order_status`). - Avoid misleading, overly promotional, or comparative language (for example, `pick_me`, `best`, `official`). #### Descriptions that match behavior Each tool must include a description that explains its purpose explicitly and accurately. - The description should describe what the tool does. - Descriptions must not favor or disparage other plugins or services or attempt to influence the model to select them over another plugin's tools. - Descriptions must not recommend overly broad triggering beyond the explicit user intent and purpose the plugin fulfills. - If a tool's behavior is unclear or incomplete from its description, the plugin may be rejected. #### Correct annotation [Tool annotations](https://developers.openai.com/plugins/reference#annotations) must be correctly set so that the model and users understand whether an action is safe or requires extra caution. - You should label a tool with the `readOnlyHint` annotation if it only retrieves or lists data and does not change anything outside the conversation. - Write or destructive tools (for example, creating, updating, deleting, posting, sending) must be explicitly marked using the `readOnlyHint` and `destructiveHint`. - Tools that interact with external systems, accounts, public platforms, or create publicly-visible content must be explicitly labeled using the `openWorldHint` annotation. - Incorrect or missing action labels are a common cause of rejection. Double-check that the `readOnlyHint`, `openWorldHint`, and `destructiveHint` annotations are correctly set, and provide a detailed justification for each when submitting the plugin. #### Minimal and purpose-driven inputs Tools should request the minimum information necessary to complete their task. - Input fields must be directly related to the tool’s stated purpose. - Do not request the full conversation history, raw chat transcripts, or broad contextual fields “just in case.” A tool may request a _brief, task-specific_ user intent field only when it meaningfully improves execution and does not expand data collection beyond what is reasonably necessary to respond to the user’s request and for the purposes described in your privacy policy. - If needed, rely on the coarse geographic location shared by the system. Do not request precise user location data (for example, GPS coordinates or addresses). #### Predictable, auditable behavior Tools should behave exactly as their names, descriptions, and inputs indicate. - Side effects should never be hidden or implicit. - If a tool sends data outside the current environment (for example, posting content, sending messages), this must be clear from the tool definition. - Tools should be safe to retry where possible, or explicitly indicate when retries may cause repeated effects. Carefully designed tools help reduce surprises, protect users, and speed up the review process. ### Authentication and permissions If your MCP server requires authentication, the flow must be transparent and explicit. Users must be informed of all requested permissions, and those requests must be limited to what is necessary for the plugin to function. #### Test credentials When submitting a plugin with an authenticated MCP server, provide a login and password for a fully featured demo account that includes sample data. Plugins that require additional login steps, such as a new account sign-up or 2FA through an inaccessible account, will be rejected. ## Commerce and monetization {/* vale off */} Currently, plugins may conduct commerce **only for physical goods**. Selling digital products or services—including subscriptions, digital content, tokens, or credits—is not allowed, whether offered directly or indirectly (for example, through freemium upsells). Users may sign in to an existing paid account and access features already included in their subscription. Plugins must not display subscription plans, initiate new subscriptions, or promote upgrades. If a certain plugin feature requires a different plan or entitlement (e.g., a different subscription tier or additional credits) than the user's, the plugin may explain that. This information should help users understand why the feature is unavailable, and should not initiate a checkout or transaction flow. Specifically, plugins may: - Explain that a certain feature is not available with the user’s current plan or entitlement. - Link to an informational page describing available plans or entitlement options. Plugins may not: - Link directly to a checkout or other transactional page. - Link to a page that explicitly initiates the process to upgrade, subscribe, or complete a purchase. Plugins should provide a high quality experience in ChatGPT. If the same feature in your plugin is also available through your external website or application, you must not provide a worse version through your plugin. Plugins must not apply ChatGPT-specific fees, surcharges, or other pricing that penalizes users for accessing a service through ChatGPT. Temporary discounts and promotional offers on other platforms are permitted. In addition, plugins may not be used to sell, promote, facilitate, or meaningfully enable the following goods or services: #### **Prohibited goods** - **Adult content & sexual services** - Pornography, explicit sexual media, live-cam services, adult subscriptions - Sex toys, sex dolls, BDSM gear, fetish products - **Gambling** - Real-money gambling services, casino credits, sportsbook wagers, crypto-casino tokens - **Illegal or regulated drugs** - Marijuana/THC products, psilocybin, illegal substances - CBD products exceeding legal THC limits - **Drug paraphernalia** - Bongs, dab rigs, drug-use scales, cannabis grow equipment marketed for drugs - **Prescription & age-restricted medications** - Prescription-only drugs (for example, insulin, antibiotics, Ozempic, opioids) - Age-restricted Rx products (for example, testosterone, HGH, fertility hormones) - **Illicit goods** - Counterfeit or replica products - Stolen goods or items without clear provenance - Financial-fraud tools (skimmers, fake POS devices) - Piracy tools or cracked software - Wildlife or environmental contraband (ivory, endangered species products) - **Malware, spyware & surveillance** - Malware, ransomware, keyloggers, stalkerware - Covert surveillance devices (spy cameras, IMSI catchers, hidden trackers) - **Tobacco & nicotine** - Tobacco products - Nicotine products (vapes, e-liquids, nicotine pouches) - **Weapons & harmful materials** - Firearms, ammunition, firearm parts - Explosives, fireworks, bomb-making materials - Illegal or age-restricted weapons (switchblades, brass knuckles, crossbows where banned) - Self-defense weapons (pepper spray, stun guns, tasers) - Extremist merchandise or propaganda #### **Prohibited fraudulent, deceptive, or high-risk services** - Fake IDs, forged documents, or document falsification services - Debt relief, credit repair, or credit-score manipulation schemes - Unregulated, deceptive, or abusive financial services - Lending, advance-fee, or credit-building schemes designed to exploit users - Crypto or NFT offerings involving speculation, consumer deception, or financial abuse - Execution of money transfers, crypto transfers, or investment trades - Government-service abuse, impersonation, or benefit manipulation - Identity theft, impersonation, or identity-monitoring services that enable misuse - Certain legal or quasi-legal services that facilitate fraud, evasion, or misrepresentation - Negative-option billing, telemarketing, or consent-bypass schemes - High-chargeback, fraud-prone, or abusive travel services ### Checkout Plugins should use external checkout, directing users to complete purchases on your own domain. Instant Checkout, which is currently in beta, is currently available only to select marketplace partners and may expand to additional marketplaces and retailers over time. Until then, standard external checkout is the required approach. No other third-party checkout solutions may be embedded or hosted within the plugin UI. To learn more, see our [docs on Agentic Commerce](https://developers.openai.com/commerce/). {/* vale on */} ### Advertising Plugins must not serve advertisements and must not exist primarily as an advertising vehicle. Every plugin must deliver clear, legitimate functionality that provides standalone value to users. ## Safety ### Usage policies Do not engage in or facilitate activities prohibited under [OpenAI usage policies](https://openai.com/policies/usage-policies/). Plugins must avoid high-risk behaviors that could expose users to harm, fraud, or misuse. Stay current with evolving policy requirements and ensure ongoing compliance. Previously approved plugins that are later found in violation may be removed. ### Appropriateness Plugins must be suitable for general audiences, including users aged 13–17. Plugins may not explicitly target children under 13. Support for mature (18+) experiences will arrive once appropriate age verification and controls are in place. ### Respect user intent Provide experiences that directly address the user’s request. Do not insert unrelated content, attempt to redirect the interaction, or collect data beyond what is reasonably necessary to fulfill the user’s request and what is consistent with your privacy policy. ### Fair play Plugins must not include descriptions, titles, tool annotations, or other model-readable fields, at either the tool or plugin level, that manipulate how the model selects or uses other plugins or their tools (for example, instructing the model to prefer one plugin over others) or interfere with fair discovery. All descriptions must accurately reflect the plugin's value without disparaging alternatives. ### Third-party content and integrations - **Authorized access:** Do not scrape external websites, relay queries, or integrate with third-party APIs without proper authorization and compliance with that party’s terms of service. - **Unofficial connectors:** We cannot approve plugins that primarily function as unofficial connectors to third-party services, including pass-through intermediary software layers. - **Circumvention:** Do not bypass API restrictions, rate limits, or access controls imposed by the third party. ### Iframes and embedded pages Plugins with UI can opt in to iframe usage by setting `frameDomains` in the resource CSP (`_meta.ui.csp.frameDomains`), but we strongly encourage you to build the UI without this pattern. If you choose to use `frameDomains`, be aware that: - It is only intended for cases where embedding a third-party experience is essential (for example, a notebook, IDE, or similar environment). - Those plugins receive extra manual review and are often not approved for broad distribution. - During development, any developer can test `frameDomains` in developer mode, but approval for public listing is limited to trusted scenarios. ## Privacy ### Privacy policy Plugin submissions must include a clear, published privacy policy explaining, at minimum, the categories of personal data collected, the purposes of use, the categories of recipients, data retention timelines, and any controls offered to your users. Follow this policy at all times. Users can review your privacy policy before installing the plugin. ### Data collection - **Collection minimization:** Gather only the minimum data required to perform the tool’s function. Inputs should be specific, narrowly scoped, and explicitly linked to the task. Avoid “just in case” fields or broad profile data. Design the input schema to limit data collection by default, rather than a funnel for optional context. - **Response minimization:** Tool responses must return only data that is directly relevant to the user’s request and the tool’s stated purpose. Do not include diagnostic, telemetry, or internal identifiers—such as session IDs, trace IDs, request IDs, timestamps, or logging metadata—unless they are strictly required to fulfill the user’s query. - **Restricted data:** Do not collect, solicit, or process the following categories of Restricted Data: - Information subject to Payment Card Information Data Security Standards (PCI DSS) - Protected health information (PHI) - Government identifiers (such as social security numbers) - Access credentials and authentication secrets (such as API keys, MFA/OTP codes, or passwords). - **Regulated Sensitive Data:** Do not collect personal data considered “sensitive” or “special category” in the jurisdiction in which the data is collected unless collection is strictly necessary to perform the tool’s stated function; the user has provided legally adequate consent; and the collection and use is explicitly and prominently disclosed at or before the point of collection. - **Data boundaries:** - Avoid requesting raw location fields (for example, city or coordinates) in your input schema. When location is needed, obtain it through the client’s controlled side channel (such as environment metadata or a referenced resource) so appropriate policy and consent controls can be applied. This reduces accidental PII capture, enforces least-privilege access, and keeps location handling auditable and revocable. - Your MCP server must not pull, reconstruct, or infer the full chat log from the client or elsewhere. Operate only on the explicit snippets and resources the client or model chooses to send. This separation can help prevent covert data expansion and keep analysis limited to intentionally shared content. ### Transparency and user control - **Data practices:** Do not engage in surveillance, tracking, or behavioral profiling—including metadata collection such as timestamps, IP addresses, or query patterns—unless explicitly disclosed, narrowly scoped, subject to meaningful user control, and aligned with [OpenAI’s usage policies](https://openai.com/policies/usage-policies/). - **Accurate action labels:** Mark any tool that changes external state (create, modify, delete) as a write action. You should only mark a tool as a read-only action if it is side-effect-free and safe to retry. Destructive actions require clear labels and friction (for example, confirmation) so clients can enforce guardrails, approvals, confirmations, or prompts before execution. - **Preventing data exfiltration:** Any action that sends data outside the current boundary (for example, posting messages, sending emails, or uploading files) must be surfaced to the client as a write action so it can require user confirmation or run in preview mode. This reduces unintentional data leakage and aligns server behavior with client-side security expectations. ## Developer verification ### Verification All plugin submissions must come from verified individuals or organizations. Inside the [OpenAI Platform Dashboard general settings](https://platform.openai.com/settings/organization/general), we provide a way to confirm your identity and affiliation with any business you wish to publish on behalf of. Misrepresentation, hidden behavior, or attempts to game the system may result in removal from the program. ### Support contact details You must provide customer support contact details where end users can reach you for help. Keep this information accurate and up to date. --- # Add UI to your MCP server ## Overview Custom UI is optional. Add it when a plugin use case requires people to inspect, compare, edit, confirm, or navigate structured information. Keep the MCP tools useful without a component so ChatGPT and Codex can complete the workflow without UI. The MCP server returns UI resources for selected tools. Components run inside an iframe in ChatGPT, communicate with the host through the MCP Apps bridge (JSON-RPC over `postMessage`), and render alongside the conversation. The open MCP Apps standard lets the UI run across compatible hosts. ## Start with MCP Apps ChatGPT implements the open [MCP Apps standard](https://modelcontextprotocol.io/docs/extensions/apps) for UI returned by an MCP server. MCP Apps defines how your server associates tools with UI resources and how the iframe communicates with its host. For new UI: 1. Declare the UI resource with `_meta.ui.resourceUri`. 2. Use the `ui/*` JSON-RPC bridge over `postMessage` for initialization, notifications, tool calls, messages, and model-visible context. 3. Keep tools useful without UI so the model can complete the workflow in clients that do not render components. This standards-first foundation lets the same UI run in ChatGPT and other compatible MCP Apps hosts. When you're ready to implement the standard, use the [MCP Apps specification](https://modelcontextprotocol.io/docs/extensions/apps). ## Layer on ChatGPT extensions After the MCP Apps flow works, use `window.openai` only for capabilities that the shared specification does not cover. These optional extensions can improve the experience in ChatGPT without making them part of the portable UI foundation. ### Prefer shared fields and methods Use the MCP Apps field or method whenever the shared specification covers the capability: | Goal | MCP Apps standard | ChatGPT compatibility alias | | ---------------------------- | ----------------------------------------------- | ----------------------------------- | | Link a tool to a UI resource | `_meta.ui.resourceUri` | `_meta["openai/outputTemplate"]` | | Receive tool input | `ui/initialize` + `ui/notifications/tool-input` | `window.openai.toolInput` | | Receive tool results | `ui/notifications/tool-result` | `window.openai.toolOutput` | | Call a tool from the UI | `tools/call` | `window.openai.callTool` | | Send a follow-up message | `ui/message` | `window.openai.sendFollowUpMessage` | The compatibility aliases remain available for existing integrations. New UI should use the shared fields and bridge methods in the middle column. Examples include: - Instant Checkout with `window.openai.requestCheckout`. - ChatGPT file handling with `window.openai.uploadFile`, `window.openai.selectFiles`, and `window.openai.getFileDownloadUrl`. - Host-controlled modals with `window.openai.requestModal`. - Widget-state persistence with `window.openai.widgetState` and `window.openai.setWidgetState`. Feature-detect each extension and provide a fallback when practical: ```js const openai = typeof window !== "undefined" ? window.openai : undefined; if (openai?.requestModal) { await openai.requestModal({ /* ... */ }); } else { // Fallback behavior for hosts without this extension. } ``` Avoid branching on a host or product name. Test for the capability your UI needs. For extension signatures and examples, see the [`window.openai` component bridge reference](https://developers.openai.com/plugins/reference#windowopenai-component-bridge). ### Optional OpenAI component library The [`@openai/apps-sdk-ui`](https://openai.github.io/apps-sdk-ui/) component library provides ready-made buttons, cards, input controls, and layout primitives that match ChatGPT's container. Use it when you want consistent styling without rebuilding base components. You can also explore the [UI examples repository on GitHub](https://github.com/openai/openai-apps-sdk-examples). ## Choose a presentation Start with inline UI and request more space only when the workflow needs it. Choose the smallest presentation that lets people understand the result or complete the task. ### Inline card Use an inline card for a focused result, confirmation, or small set of actions. Keep it self-contained and avoid deep navigation. ![Examples of inline cards](https://developers.openai.com/images/apps-sdk/inline_cards.png) ### Inline carousel Use an inline carousel when people need to scan and choose from a small set of similar, visually rich options. ![Example of an inline carousel](https://developers.openai.com/images/apps-sdk/inline_carousel.png) ### fullscreen Use fullscreen for rich tasks that need more room, such as maps, editing canvases, or detailed browsing. Design the experience to work with ChatGPT's composer, which remains available in fullscreen. ![Example of fullscreen UI](https://developers.openai.com/images/apps-sdk/fullscreen.png) ### Picture-in-picture Use picture-in-picture for an ongoing activity that should remain visible while the conversation continues, such as a live session, game, or video. ![Example of picture-in-picture UI](https://developers.openai.com/images/apps-sdk/pip.png) For detailed layout, interaction, visual design, and accessibility guidance, see [UI guidelines](https://developers.openai.com/plugins/concepts/ui-guidelines). ## Separate data processing from UI rendering ### Decoupled pattern If you attach a widget template to every tool call, ChatGPT can re-render your iframe too often. A better pattern is to separate data-processing tools from render tools: - **Data tools** fetch, compute, or mutate data and return only tool results. - **Render tools** take final data and return the widget template. This allows the model to apply its intelligence to data it fetched before choosing to render UI to the user, making it much more likely that it will accomplish the user's specific expressed goal. This pattern is part of the MCP Apps architecture. In practice, many UI integrations use this split: - **Search/fetch tools (data-first):** Return IDs plus metadata with no widget template attached. - **Render tools (for example, `render_listings_widget`):** Take a prepared list of IDs and render the widget. Only the render tool should include `_meta.ui.resourceUri`. ### Decoupled call flow Recommended call flow: 1. The model calls the data tool (for example, `roll_dice`). 2. The model receives `structuredContent` from the data tool. 3. The model calls the render tool with that data. 4. The widget renders once with final, model-checked context. ### Example: Real estate follow-up queries Suppose your plugin shows listing cards and a map, but your server-side `search` tool only supports broad filters (city, price, beds, baths) and cannot filter by school zone. If a user asks, “Which of these are in the Richmond Primary School zone?” decoupling helps: 1. `search` runs broadly and returns candidate listing IDs plus metadata. 2. The model refines that candidate set for the follow-up question. 3. The model calls `render_listings_widget` with only the filtered IDs. 4. The widget renders the final filtered set. Best practices: - Keep data tools reusable. Return complete `structuredContent` for chaining. - Keep render tools focused on presentation. Don't mix business logic into the render handler. - State the dependency in the render tool description (for example, “Always call `roll_dice` first”). - Keep reruns intentional. Let the UI call data tools directly for local interactions like “Re-roll,” without remounting the widget. ### Decoupled example Example (decoupled dice tools): ```ts const TEMPLATE_URI = "ui://widget/dice.html"; const server = new McpServer( { name: "Decoupled dice", version: "1.0.0" }, { capabilities: { tools: {} } } ); // The widget only renders the latest tool result. // Re-roll calls the data tool directly to avoid remounting the widget. const widgetHtml = `
Result:
`.trim(); server.registerResource("dice-widget", TEMPLATE_URI, {}, async () => ({ contents: [ { uri: TEMPLATE_URI, mimeType: "text/html;profile=mcp-app", text: widgetHtml, _meta: { ui: { prefersBorder: true } }, }, ], })); // 1) Data tool: no output template, returns chainable structuredContent. server.registerTool( "roll_dice", { title: "Roll dice", description: "Roll an N-sided die and return { sides, value }.", inputSchema: { sides: z.number().int().min(2) }, outputSchema: { sides: z.number().int().min(2), value: z.number().int().min(1), }, _meta: { "openai/toolInvocation/invoking": "Rolling…", "openai/toolInvocation/invoked": "Rolled.", }, }, async ({ sides }) => { const value = 1 + Math.floor(Math.random() * sides); return { structuredContent: { sides, value }, content: [{ type: "text", text: `Rolled ${value} on ${sides} sides.` }], }; } ); // 2) Render tool: owns the template and requires data from roll_dice. server.registerTool( "render_dice_widget", { title: "Render dice widget", description: "Render the dice widget from roll data. First call roll_dice, then pass its sides and value to this tool.", inputSchema: { sides: z.number().int().min(2), value: z.number().int().min(1), }, outputSchema: { sides: z.number().int().min(2), value: z.number().int().min(1), }, _meta: { ui: { resourceUri: TEMPLATE_URI }, "openai/toolInvocation/invoking": "Rendering…", "openai/toolInvocation/invoked": "Rendered.", }, }, async ({ sides, value }) => ({ structuredContent: { sides, value }, content: [ { type: "text", text: `Showing a ${sides}-sided roll: ${value}.`, }, ], }) ); export default server; ``` ## Manage state UI from an MCP server works with three kinds of state: | State type | Owner | Lifetime | Examples | | --------------------------------- | ------------------------------ | ------------------------------------ | ---------------------------------------- | | **Business data (authoritative)** | MCP server or external service | Long-lived | Tasks, tickets, documents | | **UI state (ephemeral)** | UI instance | Active UI instance | Selected row, expanded panel, sort order | | **Cross-session state (durable)** | Storage you control | Cross-session and cross-conversation | Saved filters, view mode, workspace | Keep each value with the system that owns it. The UI should render authoritative data from tool results and layer temporary presentation state on top. ```text MCP server or external service │ ├── Authoritative business data │ ▼ UI │ ├── Ephemeral presentation state │ └── Rendered view = business data + UI state ``` ### Keep business data on the server Business data is the source of truth. Do not store it only in the UI. When a user takes an action: 1. The UI calls an MCP tool. 2. The server validates the request and updates the data. 3. The server returns the updated authoritative snapshot. 4. The UI renders the snapshot while preserving compatible presentation state. Return enough structured content for both the model and UI to understand the new state. This also lets the conversation remain useful if the UI cannot load. ### Keep temporary UI state in the UI Use framework state for values that only affect presentation, such as a selected item, open panel, or draft filter. Each rendered UI instance has its own state. When the model needs to know about a selection or staged edit, send that information through `ui/update-model-context`. This is the portable MCP Apps mechanism for updating model-visible context. ChatGPT also provides optional widget-scoped persistence: - Read the current snapshot from `window.openai.widgetState`. - Write a new snapshot with `window.openai.setWidgetState(state)`. `setWidgetState` is synchronous. Call it after each meaningful UI-state change; there is nothing to `await`. ```tsx export function TaskList({ tasks }) { const [state, setState] = useState( window.openai?.widgetState ?? { selectedId: null } ); function selectTask(selectedId) { const nextState = { ...state, selectedId }; setState(nextState); window.openai?.setWidgetState?.(nextState); } return ( ); } ``` Widget state belongs to one rendered UI instance. Do not use it as the source of truth for business data or as durable storage. #### Make images visible to the model For UI that works with images, use the structured widget-state shape: - `modelContent`: Text or JSON the model should see. - `privateContent`: UI-only state the model should not see. - `imageIds`: File IDs the model should receive on later turns. ```tsx window.openai.setWidgetState({ modelContent: "Review the currently selected images.", privateContent: { currentView: "image-viewer", filters: ["crop", "sharpen"], }, imageIds: ["file_123", "file_456"], }); ``` Only include file IDs uploaded with `window.openai.uploadFile`, selected with `window.openai.selectFiles`, received through tool input file parameters, or returned through tool result file references. ### Store cross-session state on your server Store preferences and data that must survive across conversations, devices, or sessions in storage you control. Authenticate the user so the MCP server can map each request to the correct account. When you add durable storage: - Keep latency low enough for interactive UI. - Protect private data with server-side authorization. - Plan for data residency and compliance requirements. - Apply rate limits to traffic from retries or concurrent UI instances. - Version stored objects so you can migrate them without breaking existing conversations. Avoid `localStorage` for core state. UI runs in an isolated iframe, and browser storage does not provide a reliable cross-device or cross-session data layer. ## Scaffold the component project Now that you understand the MCP Apps bridge (and optional ChatGPT extensions), it’s time to scaffold your component project. As best practice, keep the component code separate from your server logic. A common layout is: ``` plugin-ui/ server/ # MCP server (Python or Node) web/ # Component bundle source package.json tsconfig.json src/component.tsx dist/component.js # Build output ``` Create the project and install dependencies (Node 18+ recommended): ```bash cd plugin-ui/web npm init -y npm install react@^18 react-dom@^18 npm install -D typescript esbuild ``` If your component requires drag-and-drop, charts, or other libraries, add them now. Keep the dependency set lean to reduce bundle size. ## Author the React component Your entry file should mount a component into a `root` element and render from the latest tool result delivered over the MCP Apps bridge (for example, `ui/notifications/tool-result`). The [examples page](https://developers.openai.com/plugins/build/examples) includes sample UI, such as the Pizzaz list of pizza restaurants. ### Explore the Pizzaz component gallery The [UI examples](https://developers.openai.com/plugins/build/examples) include example components. Treat them as blueprints when shaping your own UI: - **Pizzaz List:** Ranked card list with favorites and call-to-action buttons. ![Screenshot of the Pizzaz list component](https://developers.openai.com/images/apps-sdk/pizzaz-list.png) - **Pizzaz Carousel:** Embla-powered horizontal scroller that demonstrates media-heavy layouts. ![Screenshot of the Pizzaz carousel component](https://developers.openai.com/images/apps-sdk/pizzaz-carousel.png) - **Pizzaz Map:** Mapbox integration with fullscreen inspector and host state sync. ![Screenshot of the Pizzaz map component](https://developers.openai.com/images/apps-sdk/pizzaz-map.png) - **Pizzaz Album:** Stacked gallery view built for deep dives on a single place. ![Screenshot of the Pizzaz album component](https://developers.openai.com/images/apps-sdk/pizzaz-album.png) - **Pizzaz Video:** Scripted player with overlays and fullscreen controls. Each example shows how to bundle assets, wire host APIs, and structure state for real conversations. Copy the one closest to your use case and adapt the data layer for your tool responses. ### React helper hooks A small helper to subscribe to `ui/notifications/tool-result`: ```tsx type ToolResult = { structuredContent?: unknown } | null; export function useToolResult() { const [toolResult, setToolResult] = useState(null); useEffect(() => { const onMessage = (event: MessageEvent) => { if (event.source !== window.parent) return; const message = event.data; if (!message || message.jsonrpc !== "2.0") return; if (message.method !== "ui/notifications/tool-result") return; setToolResult(message.params ?? null); }; window.addEventListener("message", onMessage, { passive: true }); return () => window.removeEventListener("message", onMessage); }, []); return toolResult; } ``` Render from `toolResult?.structuredContent`, and treat it as untrusted input. ## Widget localization The host mirrors the locale to `document.documentElement.lang`. Use that locale to load translations and format dates/numbers. A common pattern with `react-intl`: ```tsx const messages: Record> = { "en-US": en, "es-ES": es, }; export function PluginUI() { const locale = document.documentElement.lang || "en-US"; return ( {/* Render UI with or useIntl() */} ); } ``` ## Bundle for the iframe Once you finish writing your React component, you can build it into a single JavaScript module that the server can inline: ```json // package.json { "scripts": { "build": "esbuild src/component.tsx --bundle --format=esm --outfile=dist/component.js" } } ``` Run `npm run build` to produce `dist/component.js`. If esbuild complains about missing dependencies, confirm you ran `npm install` in the `web/` directory and that your imports match installed package names (for example, `@react-dnd/html5-server-side` vs `react-dnd-html5-server-side`). ## Embed the component in the server response Expose the component as an MCP resource with the MCP Apps UI MIME type (`text/html;profile=mcp-app`). If you use `@modelcontextprotocol/ext-apps/server`, prefer `RESOURCE_MIME_TYPE` instead of embedding the string: ```ts const component = readFileSync("web/dist/component.js", "utf8"); registerAppResource( server, "project-board", "ui://project-board/v1.html", {}, async () => ({ contents: [ { uri: "ui://project-board/v1.html", mimeType: RESOURCE_MIME_TYPE, text: `
`, _meta: { ui: { prefersBorder: true, domain: "https://example.com", csp: { connectDomains: ["https://api.example.com"], resourceDomains: ["https://static.example.com"], }, }, }, }, ], }) ); ``` Associate the resource URI with only the tools that should render the component. For broader MCP Apps compatibility, use `_meta.ui.resourceUri`. ChatGPT also honors `_meta["openai/outputTemplate"]` as a compatibility alias. Treat the resource URI as a cache key. When you make a breaking change to the HTML, JavaScript, or CSS, publish a new URI and update every tool that references it. ### Content security policy (CSP) Declare the exact domains the component connects to or loads resources from: - `connectDomains` for API requests. - `resourceDomains` for scripts, styles, images, and other assets. - `frameDomains` only when the component must embed specific iframe origins. Nested frames are blocked by default. Keep each allowlist as narrow as possible. The plugin review process checks the declared policy against the UI behavior. Component UI templates are the recommended path for production. During development you can rebuild the component bundle whenever your React code changes and hot-reload the server. ## Offer checkout in your UI If you want to offer users the ability to check out through your plugin's UI flows, use the component to present products, prices, terms, and payment choices before confirmation. Keep the underlying catalog and order tools useful without UI, then choose an external checkout flow or, when available, an embedded payment option. ### Use external checkout by default External checkout is the recommended and generally available approach. Link from the component to a merchant-hosted checkout flow on your own domain, where you handle: - Pricing and payment collection. - Taxes, discounts, and fees. - Shipping and fulfillment. - Refunds, support, and compliance. Current approval is limited to plugins for physical-goods purchases. Do not offer other commerce categories unless OpenAI has explicitly enabled them for your plugin. ### Use saved payment methods For eligible physical-goods purchases, optional UI can let customers select a payment method they previously saved with your service. This flow can display eligible saved methods but cannot collect new payment credentials. Your MCP server processes the purchase and returns the authoritative order result. ### Use the ChatGPT payment sheet Embedded checkout with the ChatGPT payment sheet is in private beta for select marketplaces and is not available to all developers or users. For enabled integrations, `window.openai.requestCheckout` opens the ChatGPT payment sheet: ```tsx const order = await window.openai.requestCheckout(checkoutSession); ``` The checkout flow has four parts: 1. An MCP tool returns a checkout session in `structuredContent`. 2. The component displays the line items, totals, terms, and fulfillment choices. 3. The component calls `requestCheckout(checkoutSession)` after the user chooses to pay. 4. ChatGPT sends the selected payment token to the MCP server's `complete_checkout` tool, which charges the payment method and returns the completed order. The checkout session must include: - A unique session ID. - Line items and quantities. - Totals in integer minor currency units. - Payment-provider and merchant metadata. - Required legal, privacy, refund, and support links. Treat the server as the source of truth for prices and order status. Verify the payment token, make the operation idempotent, persist the order, and return an authoritative receipt. Never trust totals calculated only in the component. Use `payment_mode: "test"` to exercise the end-to-end flow without moving real funds. Handle cancellation, declined payments, and payment-provider errors in the component. For complete checkout-session fields, payment-provider behavior, the `complete_checkout` result shape, and delegated-payment requirements, see the [checkout API reference](https://developers.openai.com/plugins/build/monetization). --- # Authentication ## Authenticate your users Many plugin MCP servers can operate in a read-only, anonymous mode, but anything that exposes customer-specific data or write actions should authenticate users. Published plugins can run in ChatGPT and Codex. The MCP authorization contract applies across both products; this guide calls out ChatGPT-specific client details when a callback, metadata document, or linking interface differs by surface. You can integrate with your own authorization server when you need to connect to an existing server-side application or share data between users. ## Custom auth with OAuth 2.1 For an authenticated MCP server, you are expected to implement an OAuth 2.1 flow that conforms to the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). ### Components - **Resource server:** Your MCP server, which exposes tools and verifies access tokens on each request. - **Authorization server:** Your identity provider or custom implementation that issues tokens and publishes discovery metadata. - **Client:** The OpenAI host, such as ChatGPT or Codex, acting on behalf of the user. Supported clients use Client ID Metadata Documents (CIMD), dynamic client registration (DCR), predefined OAuth clients, and PKCE. ### MCP authorization spec requirements - Host protected resource metadata on your MCP server - Publish OAuth metadata from your authorization server - Echo the `resource` parameter throughout the OAuth flow - Choose how the OpenAI host identifies or registers its OAuth client: CIMD, DCR, or a predefined OAuth client - Publish the token endpoint authentication methods your authorization server accepts Here is what the spec expects, in plain language. #### Host protected resource metadata on your MCP server - You need an HTTPS endpoint such as `GET https://your-mcp.example.com/.well-known/oauth-protected-resource` (or advertise the same URL in a `WWW-Authenticate` header on `401 Unauthorized` responses) so ChatGPT knows where to fetch your metadata. - That endpoint returns a JSON document describing the resource server and its available authorization servers: ```json { "resource": "https://your-mcp.example.com", "authorization_servers": ["https://auth.yourcompany.com"], "scopes_supported": ["files:read", "files:write"], "resource_documentation": "https://yourcompany.com/docs/mcp" } ``` - Key fields you must populate: - `resource`: the canonical HTTPS identifier for your MCP server. ChatGPT sends this exact value as the `resource` query parameter during OAuth. - `authorization_servers`: one or more issuer base URLs that point to your identity provider. ChatGPT will try each to find OAuth metadata. - `scopes_supported`: optional list that helps ChatGPT explain the permissions it is going to ask the user for. - Optional extras from [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) such as `resource_documentation`, `resource_policy_uri`, or `resource_tos_uri` make it easier for clients and admins to understand your setup. When you block a request because it is unauthenticated, return a challenge like: ```http HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://your-mcp.example.com/.well-known/oauth-protected-resource", scope="files:read" ``` That single header lets ChatGPT discover the metadata URL even if it has not seen it before. #### Publish OAuth metadata from your authorization server - Your identity provider must expose one of the well-known discovery documents so ChatGPT can read its configuration: - OAuth 2.0 metadata at `https://auth.yourcompany.com/.well-known/oauth-authorization-server` - OpenID Connect metadata at `https://auth.yourcompany.com/.well-known/openid-configuration` - Each document answers three big questions for the OpenAI host: where to send the user, how to exchange codes, and how to identify itself. A typical response looks like: ```json { "issuer": "https://auth.yourcompany.com", "authorization_response_iss_parameter_supported": true, "authorization_endpoint": "https://auth.yourcompany.com/oauth2/v1/authorize", "token_endpoint": "https://auth.yourcompany.com/oauth2/v1/token", "client_id_metadata_document_supported": true, "token_endpoint_auth_methods_supported": ["none", "private_key_jwt"], "registration_endpoint": "https://auth.yourcompany.com/oauth2/v1/register", "code_challenge_methods_supported": ["S256"], "scopes_supported": ["files:read", "files:write"] } ``` - Fields that must be correct: - `issuer`: the canonical authorization server identifier. Use this exact value in the protected resource metadata `authorization_servers` list. - `authorization_response_iss_parameter_supported`: set this to `true` only when your authorization server returns an `iss` parameter in every authorization response, including error responses. - `authorization_endpoint`, `token_endpoint`: the URLs ChatGPT needs to run the OAuth authorization-code + PKCE flow end to end. - `client_id_metadata_document_supported`: set to `true` when you want ChatGPT to use CIMD for client registration. ChatGPT prioritizes CIMD when it is available, but the plugin builder can choose DCR when both CIMD and DCR are available. - `token_endpoint_auth_methods_supported`: include the token endpoint authentication methods your authorization server accepts. This applies to CIMD, DCR, and predefined OAuth clients. For CIMD, ChatGPT supports `none` for public-client token exchange and `private_key_jwt` for signed client assertion token exchange. Other OAuth clients commonly use `none`, `client_secret_post`, or `client_secret_basic`. - `registration_endpoint`: include this when you support dynamic client registration (DCR), which lets ChatGPT create and reuse a dedicated `client_id` for the connector instance. - `code_challenge_methods_supported`: must include `S256`. MCP servers are unsupported when their authorization server metadata omits this field or does not advertise `S256`, as required by the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection). - Optional fields follow [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) / [OpenID Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html); include whatever helps your administrators configure policies. #### OIDC scopes - If your provider advertises OIDC scopes (for example, `openid`, `email`, `profile`) in `scopes_supported` of its `.well-known/oauth-authorization-server` or `.well-known/openid-configuration` document, ChatGPT requests those scopes by default during the OAuth flow. - Some identity providers may not enable advertised OIDC scopes by default. Check your provider's configuration settings and make sure every advertised scope is enabled for the OAuth client, whether it uses CIMD, was created manually, or was created through DCR. #### Support workspace domain restrictions ChatGPT Enterprise workspaces can verify ownership of email domains. When an OAuth-linked plugin provides the user's verified email address, ChatGPT can use the email domain to prevent that corporate identity from linking the plugin in a personal workspace or another workspace outside the organization. To support this protection, configure your authorization server to: - Publish OpenID Connect discovery metadata. - Advertise and enable the `openid` and `email` scopes. - Advertise a UserInfo Endpoint that returns the user's `email` claim and `email_verified: true`. You can also return these claims in an ID token during the OAuth flow, but the UserInfo Endpoint is required for workspace domain restrictions. The Enterprise workspace must also verify its domain. Your authorization server provides the user identity that ChatGPT compares with verified domains configured for the workspace; it does not verify workspace ownership of a domain. #### Preserve login context during reauthorization When ChatGPT reauthorizes an existing link, including to request additional OAuth scopes, it may include the prior OIDC ID token in the authorization request as the standard `id_token_hint` parameter. To let users grant additional scopes without starting login from scratch, configure your authorization server to issue an ID token during the original OAuth flow and honor `id_token_hint` during authorization. This optimization is optional. Reauthorization still works when an ID token is unavailable or your authorization server does not use the hint. #### Protect callbacks with issuer identification OpenAI hosts use [RFC 9207 issuer identification](https://www.rfc-editor.org/rfc/rfc9207#section-2.4) to protect OAuth callbacks against authorization server mix-up attacks. To let ChatGPT and Codex use a stable redirect URI when creating an eligible OAuth client: - Set `authorization_response_iss_parameter_supported: true` in your [authorization server metadata](https://www.rfc-editor.org/rfc/rfc9207#section-3). - Use the same exact issuer identifier in the metadata `issuer` field and the protected resource metadata `authorization_servers` list. - Return `iss` in every successful and error authorization response. Its value must exactly match the metadata `issuer`; clients use exact string comparison and do not normalize trailing slashes, paths, ports, or casing. ChatGPT and Codex record the selected metadata `issuer` before redirecting the user and check the returned `iss` before exchanging the authorization code. If the server advertises issuer identification but omits `iss` or returns a mismatch, ChatGPT and Codex reject the response. These requirements follow the [MCP authorization response validation rules](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#authorization-response-validation). #### Redirect URL Copy the exact production redirect URI shown in the app management page into your authorization server's allowlist. - If your authorization server does not meet the issuer identification requirements above, ChatGPT uses the callback-ID-specific redirect URI `https://chatgpt.com/connector/oauth/{callback_id}`. - If your authorization server meets those requirements, ChatGPT uses the stable redirect URI `https://chatgpt.com/connector_platform_oauth_redirect`. Apps published before ChatGPT introduced callback-ID-specific redirects also continue to use the stable redirect URI. #### Echo the `resource` parameter throughout the OAuth flow - Expect ChatGPT to append `resource=https%3A%2F%2Fyour-mcp.example.com` to both the authorization and token requests. This ties the token back to the protected resource metadata shown above. - Configure your authorization server to copy that value into the access token (commonly the `aud` claim) so your MCP server can verify the token was minted for it and nobody else. - If a token arrives without the expected audience or scopes, reject it and rely on the `WWW-Authenticate` challenge to prompt ChatGPT to re-authorize with the correct parameters. #### Support the authorization-code flow - ChatGPT, acting as the MCP client, performs the authorization-code flow with PKCE using the `S256` code challenge so intercepted authorization codes cannot be replayed by an attacker. - Your authorization server must publish `code_challenge_methods_supported` with `S256` so clients can confirm PKCE support from metadata. ### OAuth flow Provided that you have implemented the MCP authorization spec delineated above, the OAuth flow will be as follows: 1. ChatGPT queries your MCP server for protected resource metadata. ![](https://developers.openai.com/images/apps-sdk/protected_resource_metadata.png) 2. ChatGPT identifies itself as the OAuth client. When the connector uses CIMD, ChatGPT skips dynamic client registration and sends a CIMD document URL as the `client_id`. For authorization servers that meet the issuer identification requirements above, ChatGPT uses the stable `https://chatgpt.com/oauth/client.json`; for other servers, it uses the callback-ID-specific `https://chatgpt.com/oauth/{callback_id}/client.json`. The app management page shows the exact client metadata document and redirect URI for the connector's callback mode. When the connector uses DCR, ChatGPT calls your authorization server's `registration_endpoint` once for the connector instance, receives a generated `client_id`, and reuses that client for the instance. When using CIMD, there is no client registration step. The following screen shows the DCR path: ![](https://developers.openai.com/images/apps-sdk/client_registration.png) 3. When the user first invokes a tool, the ChatGPT client launches the OAuth authorization code + PKCE flow. The user authenticates and consents to the requested scopes. ![](https://developers.openai.com/images/apps-sdk/preparing_authorization.png) 4. ChatGPT exchanges the authorization code for an access token and attaches it to subsequent MCP requests (`Authorization: Bearer `). ![](https://developers.openai.com/images/apps-sdk/auth_complete.png) 5. Your server verifies the token on each request (issuer, audience, expiration, scopes) before executing the tool. ### Client registration Use [Client ID Metadata Documents (CIMD)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents) as the preferred client registration method when your authorization server supports it and the plugin builder chooses it. With CIMD, ChatGPT uses an HTTPS metadata document URL as its `client_id`. Your authorization server fetches that document, validates the published client metadata and redirect resource identifiers, and treats the URL as ChatGPT's stable client identity. If you support CIMD, set `client_id_metadata_document_supported: true` in your authorization server metadata. This lets ChatGPT use one stable client identity for connectors that choose CIMD, which your authorization server can use for redirect URI allowlists, rate limits, and other policies. ChatGPT is adopting the CIMD transition proposed in [MCP SEP-3149](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3149). Its production CIMD document publishes `token_endpoint_auth_methods_supported` as an array of methods that ChatGPT can use, with no preference order. During the transition, it also publishes the legacy singular `token_endpoint_auth_method` as a preference: ```json { "token_endpoint_auth_methods_supported": ["none", "private_key_jwt"], "token_endpoint_auth_method": "private_key_jwt" } ``` The plural field has different perspectives in the two documents: authorization server metadata lists the methods your token endpoint accepts, while ChatGPT's CIMD document lists the methods ChatGPT can use. ChatGPT selects a method from the intersection of those sets. When the singular legacy preference is in the intersection, ChatGPT uses it for compatibility with authorization servers that still treat the singular field as binding. Otherwise, ChatGPT can use another method in the intersection. Authorization servers that read the plural CIMD field should accept any method in the intersection unless local security policy disallows that method for the client. They must reject methods outside the intersection. The `client_id` URL stays stable and does not use query parameters to select a method-specific document. The supported methods are: - `none`: use this public-client flow when your token endpoint supports PKCE-based authorization-code exchange without client authentication. ChatGPT does not store a per-client secret. - `private_key_jwt`: use this signed client assertion flow when your token endpoint requires client authentication. ChatGPT publishes a public JWKS URL in its CIMD metadata. The JWKS is served from `/oauth/jwks.json` on the metadata origin. ChatGPT signs token requests server-side with a managed private key and `kid`; your authorization server verifies the assertion against the public JWKS. DCR is still supported. If you include `registration_endpoint`, ChatGPT can register dynamically when the plugin builder chooses DCR or CIMD is not available. ChatGPT runs DCR once per MCP server connection, then keeps and reuses the registered OAuth client for that connection. DCR can still create many registered clients across many separate connections, so CIMD is usually easier to administer at scale. Keep the registered OAuth client and any client secret valid while the connector is in use. If your authorization server expires, deletes, or replaces either credential, users and reviewers may receive an `invalid_client` error when they connect. Access and refresh tokens can still expire or rotate normally. ### Client identification A frequent question is how your MCP server can confirm that a request actually comes from ChatGPT. ChatGPT presents an OpenAI-managed client certificate when connecting to MCP servers, so you can verify the client at the transport layer with mTLS. You can also allowlist ChatGPT’s [published egress IP ranges](https://developers.openai.com/api/docs/guides/ip-addresses). ChatGPT does **not** support machine-to-machine OAuth grants such as client credentials, service accounts, or JWT bearer assertions, nor can it present custom API keys or customer-provided mTLS certificates. CIMD further strengthens client identification by giving your authorization server a stable, HTTPS-hosted declaration of ChatGPT’s identity. When you use `private_key_jwt`, verify ChatGPT's token endpoint client assertion against the public JWKS published in the CIMD metadata. ### Mutual TLS (mTLS) ChatGPT now presents an OpenAI-managed client certificate when establishing TLS connections to MCP servers. If your application validates client certificates, configure it to trust the OpenAI certificate chain below. - [Download OpenAI Root CA](https://developers.openai.com/plugins/mtls/openai-root-ca.pem) - [Download OpenAI Connectors mTLS intermediate CA](https://developers.openai.com/plugins/mtls/openai-connectors-mtls-ca.pem) To validate the client certificate when establishing the TLS connection to your MCP server: - Verify a leaf certificate is present and chains to the OpenAI Connectors mTLS intermediate CA. - Verify the leaf certificate is valid for client authentication. - Verify the leaf certificate’s SAN `dnsName` is `mtls.prod.connectors.openai.com`. - Avoid pinning a leaf certificate fingerprint; OpenAI may rotate the leaf certificate while keeping it under the published CA chain. Use mTLS to authenticate ChatGPT as the MCP client. Continue to use OAuth 2.1 to authenticate the end user and authorize tool access. ### Choosing an identity provider Most OAuth 2.1 identity providers can satisfy the MCP authorization requirements once they expose a discovery document, support CIMD with `none` or `private_key_jwt`, support DCR when needed, and echo the `resource` parameter into issued tokens. Prefer providers that support CIMD for client registration. We _strongly_ recommend that you use an existing established identity provider rather than implementing authentication from scratch yourself. Here are instructions for some popular identity providers. #### Auth0 Auth0 enables MCP clients to securely connect to MCP servers by providing metadata discovery, CIMD registration, API security, and token exchange for first- and third-party tool calls. - [Guide to configuring Auth0 for MCP authorization](https://github.com/openai/openai-mcpkit/blob/main/python-authenticated-mcp-server-scaffold/README.md#2-configure-auth0-authentication) - [Auth0 securing MCP servers overview](https://auth0.com/ai/docs/mcp/intro/overview) - [Auth0 securing MCP servers quickstart guides](https://auth0.com/ai/docs/mcp/get-started/overview) #### Hosted provider example - [Provider guide to MCP authorization](https://stytch.com/docs/guides/connected-apps/mcp-server-overview) - [MCP authorization overview](https://stytch.com/blog/MCP-authentication-and-authorization-guide/) - [Authentication guide for ChatGPT UI](https://stytch.com/blog/guide-to-authentication-for-the-openai-apps-sdk/) ### Implementing token verification When the OAuth flow finishes, ChatGPT directly attaches the access token it received to subsequent MCP requests (`Authorization: Bearer …`). Once a request reaches your MCP server you must assume the token is untrusted and perform the full set of resource-server checks yourself—signature validation, issuer and audience matching, expiry, replay considerations, and scope enforcement. That responsibility sits with you, not with ChatGPT. In practice you should: - Fetch the signing keys published by your authorization server (usually via JWKS) and verify the token’s signature and `iss`. - Deny tokens that have expired or have not yet become valid (`exp`/`nbf`). - Confirm the token was minted for your server (`aud` or the `resource` claim) and contains the scopes you marked as required. - Run any app-specific policy checks, then either attach the resolved identity to the request context or return a `401` with a `WWW-Authenticate` challenge. If verification fails, respond with `401 Unauthorized` and a `WWW-Authenticate` header that points back to your protected-resource metadata. This tells the client to run the OAuth flow again. #### SDK token verification primitives Both Python and TypeScript MCP software development kits include helpers so you do not have to wire this from scratch. - [Python](https://github.com/modelcontextprotocol/python-sdk?tab=readme-ov-file#authentication) - [TypeScript](https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#proxy-authorization-requests-upstream) ## Testing and rollout - **Local testing:** Start with a development tenant that issues short-lived tokens so you can iterate quickly. - **Dogfood:** Once authentication works, gate access to trusted testers before rolling out broadly. You can require linking for specific tools or the entire connector. - **Rotation:** Plan for token revocation, refresh, and scope changes. Your server should treat missing or stale tokens as unauthenticated and return a helpful error message. - **OAuth debugging:** Use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) Auth settings to walk through each OAuth step and pinpoint where the flow breaks before you ship. With authentication in place, you can expose user-specific data and write actions to ChatGPT and Codex users. ## Triggering authentication UI ChatGPT only surfaces its OAuth linking UI when your MCP server signals that OAuth is available or necessary. Triggering the tool-level OAuth flow requires both metadata (`securitySchemes` and the resource metadata document) **and** runtime errors that carry `_meta["mcp/www_authenticate"]`. Without both halves ChatGPT will not show the linking UI for that tool. 1. **Publish resource metadata.** The MCP server must expose its OAuth configuration at a well-known URL such as `https://your-mcp.example.com/.well-known/oauth-protected-resource`. 2. **Describe each tool’s auth policy with `securitySchemes`.** Declaring `securitySchemes` per tool tells ChatGPT which tools require OAuth versus which can run anonymously. Stick to per-tool declarations even if the entire server uses the same policy; server-level defaults make it difficult to evolve individual tools later. Two scheme types are available today, and you can list more than one to express optional auth: - `noauth`: The tool is callable anonymously; ChatGPT can run it immediately. - `oauth2`: The tool needs an OAuth 2.0 access token; include the scopes you will request so the consent screen is accurate. If you omit the array entirely, the tool inherits whatever default the server advertises. Declaring both `noauth` and `oauth2` tells ChatGPT it can start with anonymous calls but that linking unlocks privileged behavior. Regardless of what you signal to the client, your server must still verify the token, scopes, and audience on every invocation. Example (public + optional auth)—TypeScript SDK ```ts declare const server: McpServer; server.registerTool( "search", { title: "Public Search", description: "Search public documents.", inputSchema: { q: z.string(), }, outputSchema: {}, securitySchemes: [ { type: "noauth" }, { type: "oauth2", scopes: ["search.read"] }, ], }, async ({ q }) => { return { content: [{ type: "text", text: `Results for ${q}` }], structuredContent: {}, }; } ); ``` Example (auth required)—TypeScript SDK ```ts declare const server: McpServer; server.registerTool( "create_doc", { title: "Create Document", description: "Make a new doc in your account.", inputSchema: { title: z.string(), }, outputSchema: {}, securitySchemes: [{ type: "oauth2", scopes: ["docs.write"] }], }, async ({ title }) => { return { content: [{ type: "text", text: `Created doc: ${title}` }], structuredContent: {}, }; } ); ``` 3. **Check tokens inside the tool handler and emit `_meta["mcp/www_authenticate"]`** when you want ChatGPT to trigger the authentication UI. Inspect the token and verify issuer, audience, expiry, and scopes. If no valid token is present, return an error result that includes `_meta["mcp/www_authenticate"]` and make sure the value contains both an `error` and `error_description` parameter. This `WWW-Authenticate` payload is what actually triggers the tool-level OAuth UI once steps 1 and 2 are in place. When a challenge prompts reauthorization, your provider can [preserve the user's existing login context](#preserve-login-context-during-reauthorization) during that flow. Example ```json { "jsonrpc": "2.0", "id": 4, "result": { "content": [ { "type": "text", "text": "Authentication required: no access token provided." } ], "_meta": { "mcp/www_authenticate": [ "'Bearer resource_metadata=\"https://your-mcp.example.com/.well-known/oauth-protected-resource\", error=\"insufficient_scope\", error_description=\"You need to login to continue\"'" ] }, "isError": true } } ``` --- # Build an MCP server Add an MCP server when a plugin use case needs live data, authentication, controlled actions, or code that runs on infrastructure you operate. The server defines the tools available to ChatGPT and Codex. It does not need to return custom UI. Start from the supported goals in your [use-case inventory](https://developers.openai.com/plugins/plan/use-case). Each tool should help complete a recognizable user goal and should expose only the data and actions required for that goal. Build the tools first. After the server works without custom UI, you can [add UI to the MCP server](https://developers.openai.com/plugins/build/chatgpt-ui) for workflows that need visual interaction. ## Choose an MCP software development kit The official software development kits provide schema helpers, server scaffolding, and streamable HTTP transport: - [TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk), published as `@modelcontextprotocol/sdk`. - [Python SDK](https://github.com/modelcontextprotocol/python-sdk), published as `mcp`. Install the SDK that matches your server stack: ```bash # TypeScript npm install @modelcontextprotocol/sdk zod # Python pip install mcp ``` ## Create the server Create an MCP server with a stable name and version: ```ts const server = new McpServer({ name: "acme-projects", version: "1.0.0", }); ``` MCP servers can also return an [`instructions` field](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#initialization) during initialization. ChatGPT and Codex use these instructions alongside tool metadata. Use server instructions for guidance that applies across tools, such as required tool sequences or shared rate limits. Keep the most important details in the first 512 characters. Do not repeat every tool description or try to change the model's personality. ```ts const server = new McpServer( { name: "acme-projects", version: "1.0.0" }, { instructions: "Before updating a project, call get_project to confirm its ID and current status.", } ); ``` ## Define tools from user goals Create one tool for each distinct action the plugin must support. Prefer focused operations such as `list_projects`, `get_project`, and `update_project` over one tool with many unrelated modes. Each tool needs: - An action-oriented name and human-readable title. - A description that explains when to use it. - An explicit input schema. - An output schema when the tool returns structured data. - Accurate safety annotations. - A handler that authorizes the request and performs the operation. The model uses this metadata to decide whether and how to call the tool. Treat names, descriptions, schemas, and annotations as part of the plugin's user-facing behavior. ```ts server.registerTool( "list_projects", { title: "List projects", description: "Use this when the user wants to find or review projects in their Acme workspace.", inputSchema: { status: z.enum(["active", "archived"]).optional(), }, outputSchema: { projects: z.array( z.object({ id: z.string(), name: z.string(), status: z.string(), }) ), }, annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, }, }, async ({ status }) => { const projects = await listProjects({ status }); return { structuredContent: { projects }, content: [ { type: "text", text: `Found ${projects.length} projects.`, }, ], }; } ); ``` ## Return useful results without UI A tool result can include: - `structuredContent`: concise data the model can inspect and use in later calls. - `content`: text or other MCP content that helps the model answer the user. - `_meta`: client-specific data hidden from the model. Return enough information for the model to complete the workflow without a component. Use stable identifiers in structured results so later tools can refer to the same records. Do not put secrets, access tokens, or unnecessary personal data in tool results. Treat `_meta` as hidden from the model, not as a substitute for authorization or secure storage. ## Import skills from the MCP server Configure the MCP server to supply skills when you want to version and deploy their instructions and supporting files with the server. During plugin submission, **Scan Tools** imports a static snapshot of those skills into the draft. OpenAI currently supports a bounded, static subset of the [draft SEP-2640 Skills extension](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640). This proposal is not yet part of the stable MCP specification. ### Advertise the extension Declare `io.modelcontextprotocol/skills` in the server's initialization capabilities: ```json { "capabilities": { "extensions": { "io.modelcontextprotocol/skills": {} } } } ``` The declaration must be under `capabilities.extensions`. OpenAI does not recognize the earlier `experimental` declaration. ### List the skills and their resources Support the paginated `skills/list` method. Each entry must include: - A `uri` that points to the skill's `SKILL.md`. - `frontmatter` containing every entry from the parsed `SKILL.md` front matter. Include the `name` and `description` entries. - A complete `resources` list containing `SKILL.md` and every supporting file. - A SHA-256 digest for each resource in the form `sha256:<64 lowercase hexadecimal characters>`. Use the `skill://` URI convention. The directory containing `SKILL.md` must match the skill name. For example: ```json { "skills": [ { "uri": "skill://dice-roller/tabletop-dice/SKILL.md", "frontmatter": { "name": "tabletop-dice", "description": "Roll one or more dice and report each result and the total." }, "resources": [ { "uri": "skill://dice-roller/tabletop-dice/SKILL.md", "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }, { "uri": "skill://dice-roller/tabletop-dice/references/notation.md", "digest": "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" } ] } ], "nextCursor": "optional-next-page-cursor" } ``` The example digests show the required format. For a text resource, hash the UTF-8 bytes of `content.text`. For a blob resource, base64-decode `content.blob`, then hash the decoded bytes. Also support `skills/get` for each listed `SKILL.md` URI. Return a `skill` object with the same complete entry shape as `skills/list`. Use these request parameters: - For the first `skills/list` request, accept an empty object (`{}`). - For each later `skills/list` request, accept the returned cursor, such as `{ "cursor": "next-page-cursor" }`. - For `skills/get`, accept the catalog URI, such as `{ "uri": "skill://dice-roller/tabletop-dice/SKILL.md" }`. ### Return every listed resource Support `resources/read` for every URI in the manifest. Return exactly one content item whose URI matches the request. OpenAI accepts UTF-8 text or a base64-encoded blob. During import, OpenAI verifies that: - OpenAI can fetch every listed resource and confirm its digest. - The fetched `SKILL.md` front matter exactly matches the catalog entry. - Resource paths are safe, unique, and free of normalization conflicts. - The complete skill fits the import limits. The importer accepts up to five uniquely named skills across 10 catalog pages. Each skill can contain up to 100 files, with these size limits: | Content | Limit | | ------------------------------------- | ------- | | `SKILL.md` | 256 KiB | | Each supporting file | 1 MiB | | All resources for one skill | 5 MiB | | Generated skill archives for one scan | 8 MiB | The combined archive limit includes ZIP packaging overhead. If any entry fails validation or exceeds a limit, **Scan Tools** still returns the server's tools but does not update the draft's imported skills. Fix the server and scan again. Skills imported from MCP are submission-time snapshots, not live runtime resources. After changing a skill, run **Scan Tools** again, review the imported skills, and submit a new plugin version. See [Submit plugins](https://developers.openai.com/plugins/deploy/submission#mcp) for the complete flow. ## Authenticate and authorize requests Add authentication when a tool reads private data or takes action for a user. Enforce authorization in the MCP server for every request; never rely on the model to decide whether a user has access. See [Authenticate users](https://developers.openai.com/plugins/build/auth) for OAuth discovery, security schemes, and authorization challenges. ## Tool annotations and elicitation Set annotations according to actual behavior: - `readOnlyHint`: `true` only when the tool cannot change state. - `destructiveHint`: `true` when a tool can cause irreversible or difficult to reverse outcomes. - `openWorldHint`: `true` when a tool can affect public or external systems. Annotations help ChatGPT and Codex choose appropriate confirmation and safety behavior. They do not replace authorization, validation, or confirmation in your server. Use MCP elicitation when the server needs structured information that was not provided in the original tool call. Keep elicitation focused on information the user can reasonably supply. Do not use it to collect secrets or bypass normal authentication. ## Company knowledge compatibility Company knowledge can use read-only tools from your MCP server. To make a plugin eligible as a company knowledge source, implement the standard `search` and `fetch` tool input schemas and mark other read-only tools with `readOnlyHint: true`. Return absolute, user-openable URLs for sources that the model should cite. Keep internal document identifiers in the result's `id` field. For the required schemas and result shapes, see [Building MCP servers for ChatGPT and API integrations](https://platform.openai.com/docs/mcp). ## Run and test locally Expose a streamable HTTP endpoint, typically at `/mcp`, then inspect it with [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector): ```bash npx @modelcontextprotocol/inspector ``` In the Inspector UI, select **Streamable HTTP** and enter `http://localhost:3000/mcp`. Use the inspector to: 1. Confirm that initialization succeeds. 2. Review server instructions and the advertised tool list. 3. Call every tool with representative and invalid inputs. 4. Verify schemas, results, errors, and annotations. 5. Confirm that authorization is enforced for private data and write actions. Then connect the server to ChatGPT in [developer mode](https://developers.openai.com/plugins/deploy/connect-chatgpt) and run the direct, indirect, edge-case, and out-of-scope requests from your use-case inventory. ## Deploy the endpoint For public plugin submission, deploy the MCP server at a stable, publicly reachable HTTPS endpoint. [Secure MCP Tunnel](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels) can connect a private MCP server in developer mode, but it does not satisfy public submission requirements. The production endpoint must: - Support the MCP streamable HTTP transport. - Respond at a stable URL, typically ending in `/mcp`. - Meet the latency and availability needs of the plugin's workflows. - Reach required services and data stores. - Preserve authentication and authorization boundaries. - Produce logs and metrics for failed initialization and tool calls. If the MCP server must remain private, deploy a public HTTPS proxy that forwards MCP requests to the private server. Use [OpenAI-managed mTLS](https://developers.openai.com/plugins/build/auth#mutual-tls-mtls) to authenticate ChatGPT as the MCP client, and use [OAuth 2.1](https://developers.openai.com/plugins/build/auth) when your plugin requires user authentication. If your network requires an IP allowlist, use the published [ChatGPT connectors IP ranges](https://developers.openai.com/api/docs/guides/ip-addresses) and update the allowlist automatically. An IP allowlist does not replace authentication or authorization. The public endpoint must remain reachable for plugin review and [domain verification](https://developers.openai.com/plugins/deploy/submission#domain-verification). Do not use Secure MCP Tunnel alone, a temporary tunnel, or a local endpoint for public submission. ### Choose infrastructure You can deploy the MCP server to serverless, container, edge, or traditional application infrastructure. Choose a platform based on: - Runtime and dependency support. - Streaming response behavior. - Cold-start and request latency. - Network access to required services. - Data residency and compliance requirements. - Secret management. - Logging, tracing, and alerting. - Rollback and versioning support. If the server also hosts optional UI assets, deploy those assets at stable origins allowed by the component's [content security policy](https://developers.openai.com/plugins/build/chatgpt-ui#content-security-policy-csp). ### Configure the production endpoint Before deployment: 1. Set production credentials through the host's secret-management system. 2. Configure the authorization server and allowed redirect behavior. 3. Apply timeouts and rate limits to expensive or externally visible tools. 4. Remove debug responses and unnecessary personal data. 5. Confirm that logs do not contain access tokens or sensitive tool results. After deployment, call the production endpoint with [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector). Verify initialization, server instructions, tools, schemas, annotations, authentication, results, and errors. ### Plan for updates Keep published tool names and schemas backward compatible. Add fields or tools without breaking existing contracts. If metadata changes, refresh the developer-mode connection and rerun the evaluation set before submission. For optional UI, version resource identifiers when HTML, JavaScript, or CSS changes in a way that could break a cached component. ## Add optional UI After tools work end to end, decide whether any use case needs visual interaction. A table, map, editable schedule, or comparison view may benefit from UI. A lookup, status check, or background action often does not. Continue with [Add UI to your MCP server](https://developers.openai.com/plugins/build/chatgpt-ui) to register an MCP Apps resource and associate it with selected tools. ## Security reminders - Treat every tool input as untrusted. - Validate parameters and enforce authorization on the server. - Require confirmation for consequential write actions. - Keep secrets and sensitive data out of tool metadata and results. - Log enough context to investigate failures without logging credentials or unnecessary personal data. - Rate-limit expensive or externally visible actions. --- # Build skills A skill complements your MCP server by teaching ChatGPT and Codex how to use its tools in a repeatable workflow. Use the server for live data, authentication, authorization, and controlled actions. Use the skill for tool sequences, decision points, output requirements, examples, templates, and other reusable guidance. A plugin can contain one skill or a group of related skills. Keep every skill focused on a recognizable user goal from your [use-case inventory](https://developers.openai.com/plugins/plan/use-case). A skill can also work without an MCP server when the workflow needs only packaged instructions and resources. ## Create a skill The fastest way to start is with the built-in skill creator. Describe the user goal and the MCP tools that support it: ```text @skill-creator Create a skill named tabletop-dice that understands dice notation such as 3d6, calls roll_dice once for each die, and reports every roll and the total. ``` In Codex, invoke the same creator as `$skill-creator`. You can also create the files manually. Each skill lives in its own directory and requires a `SKILL.md` file: ## Write `SKILL.md` Start the file with a name and a description, followed by the instructions: ```md --- name: tabletop-dice description: Roll one or more dice for tabletop games and report each result and the total. --- Use this skill when the user asks to roll dice. 1. Parse requests written as `NdS` as N dice with S sides. For example, `3d6` means three six-sided dice. 2. Call `roll_dice` once for each requested die and pass S as `sides`. 3. Report each tool result in order. 4. When the user requests multiple dice, add the results and report the total. Do not invent, replace, or reroll a result unless the user asks you to. ``` The description determines when the model considers the skill. State the workflow and the conditions that should trigger it. Put detailed procedure, format, and safety instructions in the body. ## Define the workflow boundary Connect every skill to one or more use cases. The instructions should make the following clear: - What input the workflow expects. - Which steps the model should follow. - What output the user should receive. - Which facts the model must not infer. - When the workflow should ask a question, stop, or decline. - Which supporting files the model should consult. Prefer one focused skill over a large collection of loosely related instructions. Split workflows when they have different triggers, inputs, or success criteria. ## Add supporting resources Keep `SKILL.md` concise and place detailed material next to it: - Use `references/` for policies, schemas, examples, and background material. - Use `assets/` for templates or files the workflow should copy or transform. - Use `scripts/` when the workflow needs deterministic computation or file processing. Reference supporting files from `SKILL.md` and explain when to load or run them. Do not add a script when instructions and existing tools can complete the task reliably. ## Connect skills to MCP tools A skill can guide the model through tools exposed by the plugin's MCP server. Use the skill for workflow instructions and the server for live data, authorization, and controlled actions. If a skill requires an MCP server, declare the dependency in `agents/openai.yaml`: ```yaml dependencies: tools: - type: "mcp" value: "dice-roller" description: "Roll an N-sided die" transport: "streamable_http" url: "https://tinymcp.dev/api/moldy-aloof-zettabyte/mcp" ``` A dependency makes the required tool available; it does not replace clear workflow instructions. Tell the model which tools to use, in what order, and how to handle missing or ambiguous results. ## Import a skill from MCP You can upload a packaged skill during submission or import it from the plugin's MCP server. The MCP option keeps the skill's instructions and supporting files with the server deployment. OpenAI imports skills from MCP when you select **Scan Tools** in the plugin submission portal. The imported files become a snapshot in the draft; ChatGPT and Codex do not fetch them from your MCP server at runtime. After changing the skill, deploy the server and scan it again before submitting a new plugin version. For the capability declaration, discovery methods, resource manifest, and import limits, see [Import skills from the MCP server](https://developers.openai.com/plugins/build/mcp-server#import-skills-from-the-mcp-server). ## Test the skill Test with representative requests from the use-case inventory: 1. Direct requests that should activate the skill. 2. Indirect requests that express the same goal. 3. Incomplete inputs that should trigger a follow-up question. 4. Requests that should not activate the skill. 5. Edge cases where the skill must avoid inventing information or taking an unsupported action. Review both activation and output quality. Refine the description when the skill activates at the wrong time. Refine the instructions when it chooses the right workflow but produces an inconsistent result. ## Package the skill Point the plugin manifest at the skills directory: ```json { "name": "dice-roller", "version": "1.0.0", "description": "Roll dice for tabletop games", "skills": "./skills/", "apps": "./.app.json" } ``` See [Package your plugin](https://developers.openai.com/plugins/build/plugins) for the complete manifest, MCP server mapping, local testing, and distribution flow. --- # Checkout API reference ## Overview Plugin developers are responsible for choosing how to monetize their experience. Today, the **recommended** and **generally available** approach is to use **external checkout**, where users complete purchases on the developer’s own domain. While current approval is limited to plugins for physical goods purchases, we are actively working to support a wider range of commerce use cases. We’re also enabling **embedded checkout with the ChatGPT payment sheet** for select marketplace partners (beta), with plans to extend access to more marketplaces and physical-goods retailers over time. Until then, we recommend routing purchase flows to your standard external checkout. ## Recommended Monetization Approach ### ✅ External Checkout (recommended) **External checkout** means directing users from ChatGPT to a **merchant-hosted checkout flow** on your own website or application, where you handle pricing, payments, shipping, and fulfillment for eligible physical goods. This is the recommended approach for most plugin developers. #### How it works 1. A user interacts with your plugin UI in ChatGPT. 2. Your plugin UI presents eligible physical goods (for example, with a “Buy now” action). 3. When the user decides to purchase, your plugin UI links or redirects them out of ChatGPT and to your external checkout flow. 4. Payment, billing, taxes, refunds, and compliance are handled entirely on your domain. 5. After purchase, the user can return to ChatGPT with order confirmation or tracking details. ### Checkout with saved payment methods Plugin developers can build a checkout flow in optional UI that allows customers to use payment methods already saved with the merchant. This flow can only display saved payment methods and cannot collect new payment method credentials from customers. In this approach, the customer does not need to be redirected to another surface outside ChatGPT to complete the purchase. #### How it works 1. A user interacts with your plugin UI in ChatGPT. 2. Your plugin UI presents eligible physical goods with the relevant totals. 3. Your plugin UI displays eligible payment methods that the customer has already saved with you. 4. The customer selects a saved payment method and confirms the purchase in ChatGPT. 5. Your server processes the purchase with the saved payment method and returns confirmation to the plugin. ### Checkout with the ChatGPT payment sheet (private beta) Checkout with the ChatGPT payment sheet is limited to select marketplaces today and is not available to all users. To collect new payment methods within the checkout flow, plugin developers must use the ChatGPT payment sheet. Call `requestCheckout` with checkout session data (line items, totals, saved payment methods) to open the sheet. When the user selects buy, ChatGPT sends a token representing the selected payment method to your MCP server through the `complete_checkout` tool call. Use your PSP integration to collect payment with this token, then return finalized order details from `complete_checkout`. ### Flow at a glance 1. **Server prepares session**: An MCP tool returns checkout session data (session id, line items, totals, payment provider) in `structuredContent`. 2. **Widget previews cart**: The widget renders line items and totals so the user can confirm. 3. **Widget calls `requestCheckout`**: The widget invokes `requestCheckout(session_data)`. ChatGPT opens the payment sheet, displays the amount to charge, and displays various payment methods. 4. **Server finalizes**: Once the user clicks the pay button, the widget calls back to your MCP via the `complete_checkout` tool call. The MCP tool returns the completed order, which will be returned back to widget as a response to `requestCheckout`. ## Checkout session You are responsible for constructing the checkout session payload that the host will render. The exact values for certain fields such as `id` and `payment_provider` depend on your payment service provider and commerce system. In practice, your MCP tool should return: - Line items and quantities the user is purchasing. - Totals (subtotal, tax, discounts, fees, total) that match your server calculations. - Provider metadata required by your PSP integration. - Legal and policy links (terms, refund policy, etc.). ## Widget: Call `requestCheckout` The host provides `window.openai.requestCheckout`. Use it to open the ChatGPT payment sheet when the user initiates a purchase: Example: {/* vale off */} ```tsx async function handleCheckout(sessionJson: string) { const session = JSON.parse(sessionJson); if (!window.openai?.requestCheckout) { throw new Error("requestCheckout is not available in this host"); } // Host opens the ChatGPT payment sheet. const order = await window.openai.requestCheckout({ ...session, id: String(checkout_session_id), // Use a unique ID for every checkout session. }); return order; // Host returns the order payload. } ``` {/* vale on */} In your component, you might initiate this in a button click: ```tsx { setIsLoading(true); try { const orderResponse = await handleCheckout(checkoutSessionJson); setOrder(orderResponse); } catch (error) { console.error(error); } finally { setIsLoading(false); } }} > {isLoading ? "Loading..." : "Checkout"} ``` Here is a complete checkout session example that your widget can pass to the host. Your plugin supplies the checkout session fields below. ChatGPT adds host-owned fields such as `merchant`, `logo_url`, `conversation_id`, `connector_id`, and `ecosystem_app_uri`. Populate the `merchant_id` field with the value specified by your PSP: ```tsx const checkoutRequest = { id: "checkout_session_123", payment_provider: { provider: "stripe", merchant_id: "merchant_123", supported_payment_methods: [ { type: "card", allowed_card_brands: ["visa", "mastercard"], }, { type: "apple_pay" }, { type: "google_pay" }, ], managed_payment_methods: [ { type: "card", id: "pm_123", display_name: "Visa ending in 4242", display_last4: "4242", display_brand: "visa", }, ], }, payment_mode: "live", status: "ready_for_payment", currency: "USD", metadata: { cart_id: "cart_123", merchant_order_reference: "order_ref_123", }, line_items: [ { id: "line_item_123", item: { id: "item_123", quantity: 1, }, name: "Canvas backpack", description: "A weather-resistant everyday backpack.", images: ["https://merchant.example.com/images/canvas-backpack.png"], base_amount: 3000, discount: 0, subtotal: 3000, tax: 300, total: 3300, }, ], totals: [ { type: "items_base_amount", display_text: "Items subtotal", amount: 3000, }, { type: "subtotal", display_text: "Subtotal", amount: 3000, }, { type: "fulfillment", display_text: "Shipping", amount: 550, }, { type: "tax", display_text: "Tax", amount: 300, }, { type: "total", display_text: "Total", amount: 3850, }, ], fulfillment_options: [ { id: "standard_shipping", type: "shipping", title: "Standard shipping", subtitle: "Arrives in 3-5 business days", carrier: "USPS", earliest_delivery_time: "2027-01-15T15:00:00Z", latest_delivery_time: "2027-01-19T18:00:00Z", subtotal: 500, tax: 50, total: 550, }, ], fulfillment_option_id: "standard_shipping", fulfillment_address: { name: "Jane Customer", line_one: "123 Main St", line_two: "Apt 4B", city: "San Francisco", state: "CA", country: "US", postal_code: "94107", phone_number: "+14155550123", }, messages: [ { type: "info", param: "fulfillment_address", content_type: "plain", content: "Free returns within 30 days.", }, ], links: [ { type: "terms_of_use", url: "https://merchant.example.com/terms" }, { type: "privacy_policy", url: "https://merchant.example.com/privacy" }, { type: "support_url", url: "https://merchant.example.com/support" }, ], }; const response = await window.openai.requestCheckout(checkoutRequest); ``` Key points: - `window.openai.requestCheckout(session)` opens the host checkout UI. - The promise resolves with the order result or rejects on error/cancel. - Render the session JSON so users can review what they’re paying for. - Use integer minor currency units for all amount fields. - Use `payment_provider.managed_payment_methods` for payment methods the customer has already saved with your merchant. - Keep `metadata` values as strings. - Use the PSP slug required by your integration for `provider`, and consult your PSP to get its `merchant_id` value. ## MCP server: Expose the `complete_checkout` tool You can mirror this pattern and swap in your logic: For direct `CallToolResult` returns, the Python MCP SDK uses the `Annotated` return type below to declare the tool `outputSchema` for `structuredContent`. ```py from typing import Annotated, Any from pydantic import BaseModel class CompleteCheckoutOutput(BaseModel): id: str status: str currency: str line_items: list[dict[str, Any]] fulfillment_address: dict[str, Any] fulfillment_options: list[dict[str, Any]] fulfillment_option_id: str totals: list[dict[str, Any]] order: dict[str, Any] @tool(description="") async def complete_checkout( self, checkout_session_id: str, buyer: Buyer, payment_data: PaymentData, ) -> Annotated[types.CallToolResult, CompleteCheckoutOutput]: return types.CallToolResult( content=[], structuredContent={ "id": checkout_session_id, "status": "completed", "currency": "USD", "line_items": [ { "id": "line_item_1", "item": { "id": "item_1", "quantity": 1, }, "base_amount": 3000, "discount": 0, "subtotal": 3000, "tax": 300, "total": 3300, }, ], "fulfillment_address": { "name": "Jane Customer", "line_one": "123 Main St", "line_two": "Apt 4B", "city": "San Francisco", "state": "CA", "country": "US", "postal_code": "94107", "phone_number": "+1 (555) 555-5555", }, "fulfillment_options": [ { "id": "fulfillment_option_1", "type": "shipping", "title": "Standard shipping", "subtitle": "3-5 business days", "carrier": "USPS", "earliest_delivery_time": "2026-02-24T15:00:00Z", "latest_delivery_time": "2026-02-28T18:00:00Z", "subtotal": 0, "tax": 0, "total": 0, }, ], "fulfillment_option_id": "fulfillment_option_1", "totals": [ { "type": "items_base_amount", "display_text": "Items subtotal", "amount": 3000, }, { "type": "subtotal", "display_text": "Subtotal", "amount": 3000, }, { "type": "tax", "display_text": "Tax", "amount": 300, }, { "type": "total", "display_text": "Total", "amount": 3300, }, ], "order": { "id": "order_id_123", "checkout_session_id": checkout_session_id, "permalink_url": "", }, }, _meta={META_SESSION_ID: "checkout-flow"}, isError=False, ) ``` Adapt this to: - Integrate with your payment service provider to charge the payment method within `payment_data`. - Persist the order in your system. - Return authoritative order/receipt data. - Include `_meta.ui.resourceUri` if you want to render a confirmation widget (ChatGPT honors `_meta["openai/outputTemplate"]` as an optional compatibility alias). The following payment service providers support processing for the ChatGPT payment sheet: {/* vale off */} - [Adyen](https://docs.adyen.com/online-payments/agentic-commerce) - [Checkout.com](https://api-reference.checkout.com/tag/Agentic-Commerce-Protocol/) - Fiserv - [PayPal](https://docs.paypal.ai/growth/agentic-commerce/agent-ready) - [Stripe](https://docs.stripe.com/agentic-commerce/apps) - [Worldpay](https://docs.worldpay.com/access/products/ai/acp) ## Optional: Receive Raw Payment Methods If you are a merchant with a PCI DSS Level 1 certificate, you can receive raw payment methods directly by implementing the Agentic Commerce Protocol Delegate Payment endpoint. The delegated payment request will include the full payment method details your payment flow requires, including the raw card number, expiration date, CVC, billing address, allowance constraints, risk signals, and metadata. {/* vale on */} For example, a raw card payment method request is as follows: ```json { "payment_method": { "type": "card", "card_number_type": "fpan", "number": "4242424242424242", "exp_month": "11", "exp_year": "2026", "name": "Jane Doe", "cvc": "223", "checks_performed": ["avs", "cvv"], "iin": "424242", "display_card_funding_type": "credit", "display_brand": "visa", "display_last4": "4242", "metadata": {} }, "allowance": { "reason": "one_time", "max_amount": 5000, "currency": "usd", "checkout_session_id": "cs_01HV3P3ABC123", "merchant_id": "acme_corp", "expires_at": "2026-02-13T12:00:00Z" }, "billing_address": { "name": "Jane Doe", "line_one": "185 Berry Street", "line_two": "Suite 550", "city": "San Francisco", "state": "CA", "country": "US", "postal_code": "94107" }, "risk_signals": [ { "type": "card_testing", "score": 5, "action": "authorized" } ], "metadata": { "session_id": "sess_abc123", "user_agent": "ChatGPT/2.0" } } ``` The corresponding response should return an id representing the payment method. This id will be passed to `complete_checkout` as part of `payment_data`. ```json { "id": "vt_01J8Z3WXYZ9ABC123", "created": "2026-02-12T14:30:00Z", "metadata": { "source": "agent_checkout", "merchant_id": "acme_corp", "idempotency_key": "idem_xyz789" } } ``` ## Error Handling The `complete_checkout` tool call can send back messages of type `error`. Error messages with `code` set to `payment_declined` or `requires_3ds` will be displayed on the ChatGPT payment sheet. All other error messages will be sent back to the widget as a response to `requestCheckout`. The widget can display the error as desired. ## Test payment mode You can set the value of the `payment_mode` field to `test` in the call to `requestCheckout`. This will present a ChatGPT payment sheet that accepts test cards (such as the 4242 test card). The resulting `token` within `payment_data` that is passed to the `complete_checkout` tool can be processed in the staging environment of your PSP. This allows you to test end-to-end flows without moving real funds. Note that in test payment mode, you might have to set a different value for `merchant_id`. Refer to your payment provider's monetization guide for more details. ## Implementation checklist 1. **Define your checkout session model**: Include IDs, the payment provider object, line items, totals, and legal links. 2. **Return the session from your MCP tool** in `structuredContent` alongside your widget template. 3. **Render the session in the widget** so users can review items, totals, and terms. 4. **Call `requestCheckout(session_data)`** on user action; handle the resolved order or error. 5. **Charge the user** by implementing the `complete_checkout` MCP tool which returns a response that follows the checkout spec. 6. **Test end-to-end** with realistic amounts, taxes, and discounts to ensure the host renders the totals you expect. --- # Examples ## Overview The Pizzaz demo bundles several UI components so you can see the full tool surface area end to end. The following sections walk through the MCP server and the component implementations that power those tools. You can find Pizzaz and other examples in our [examples repository on GitHub](https://github.com/openai/openai-apps-sdk-examples). Use these examples as blueprints when you assemble your plugin's MCP server and optional UI. --- # MCP server and UI quickstart ## Introduction Plugins use the [Model Context Protocol (MCP)](https://developers.openai.com/plugins/concepts/mcp-server) to expose server-backed capabilities to ChatGPT and Codex. This tutorial uses: 1. An MCP server that defines tools and exposes them to ChatGPT and Codex. 2. An optional web component, rendered in an iframe inside ChatGPT. ChatGPT implements the open MCP Apps UI standard so you can build your UI once and run it across MCP Apps-compatible hosts. In this quickstart, we'll build a basic to-do workflow with UI contained in a single HTML file that keeps the markup, CSS, and JavaScript together. To see more advanced examples using React, see the [examples repository on GitHub](https://github.com/openai/openai-apps-sdk-examples). ## Build a web component This step is optional. If you only need tools and no ChatGPT UI, skip to [Build an MCP server](#build-an-mcp-server) and do not register a UI resource. Start by creating a file called `public/todo-widget.html` in a new directory. ChatGPT will render this UI when the associated MCP tool returns it. This file will contain the web component that will be rendered in the ChatGPT interface. Add the following content: ```html Todo list

Todo list

    ``` ### Use MCP Apps in your web component For new UI, use the MCP Apps host bridge: JSON-RPC over `postMessage` with `ui/*` notifications and methods such as `tools/call`. After the shared MCP Apps flow works, add optional ChatGPT extensions through `window.openai` only when you need capabilities the standard does not cover. For details, see [Add UI to your MCP server](https://developers.openai.com/plugins/build/chatgpt-ui#layer-on-chatgpt-extensions). ## Build an MCP server Install the official Python or Node MCP SDK to create a server and expose a `/mcp` endpoint. In this quickstart, we'll use the [Node SDK](https://github.com/modelcontextprotocol/typescript-sdk). If you're using Python, refer to our [examples repository on GitHub](https://github.com/openai/openai-apps-sdk-examples) to see an example MCP server with the Python SDK. Install the Node SDK, MCP Apps helpers, and the `zod` package with: ```bash npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod ``` ### MCP server with UI resources Register a resource for your component bundle and the tools the model can call (for example, `add_todo` and `complete_todo`) so ChatGPT can drive the UI. Create a file named `server.js` and paste the following example that uses the Node SDK: ```js const todoHtml = readFileSync("public/todo-widget.html", "utf8"); const addTodoInputSchema = { title: z.string().min(1), }; const completeTodoInputSchema = { id: z.string().min(1), }; const todoOutputSchema = { tasks: z.array( z.object({ id: z.string(), title: z.string(), completed: z.boolean(), }) ), }; let todos = []; let nextId = 1; const replyWithTodos = (message) => ({ content: message ? [{ type: "text", text: message }] : [], structuredContent: { tasks: todos }, }); function createTodoServer() { const server = new McpServer({ name: "todo-plugin-server", version: "0.1.0", }); registerAppResource( server, "todo-widget", "ui://widget/todo.html", {}, async () => ({ contents: [ { uri: "ui://widget/todo.html", mimeType: RESOURCE_MIME_TYPE, text: todoHtml, }, ], }) ); registerAppTool( server, "add_todo", { title: "Add todo", description: "Creates a todo item with the given title.", inputSchema: addTodoInputSchema, outputSchema: todoOutputSchema, _meta: { ui: { resourceUri: "ui://widget/todo.html" }, }, }, async (args) => { const title = args?.title?.trim?.() ?? ""; if (!title) return replyWithTodos("Missing title."); const todo = { id: `todo-${nextId++}`, title, completed: false }; todos = [...todos, todo]; return replyWithTodos(`Added "${todo.title}".`); } ); registerAppTool( server, "complete_todo", { title: "Complete todo", description: "Marks a todo as done by id.", inputSchema: completeTodoInputSchema, outputSchema: todoOutputSchema, _meta: { ui: { resourceUri: "ui://widget/todo.html" }, }, }, async (args) => { const id = args?.id; if (!id) return replyWithTodos("Missing todo id."); const todo = todos.find((task) => task.id === id); if (!todo) { return replyWithTodos(`Todo ${id} was not found.`); } todos = todos.map((task) => task.id === id ? { ...task, completed: true } : task ); return replyWithTodos(`Completed "${todo.title}".`); } ); return server; } const port = Number(process.env.PORT ?? 8787); const MCP_PATH = "/mcp"; const httpServer = createServer(async (req, res) => { if (!req.url) { res.writeHead(400).end("Missing URL"); return; } const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`); if (req.method === "OPTIONS" && url.pathname === MCP_PATH) { res.writeHead(204, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, GET, OPTIONS", "Access-Control-Allow-Headers": "content-type, mcp-session-id", "Access-Control-Expose-Headers": "Mcp-Session-Id", }); res.end(); return; } if (req.method === "GET" && url.pathname === "/") { res.writeHead(200, { "content-type": "text/plain" }).end("Todo MCP server"); return; } const MCP_METHODS = new Set(["POST", "GET", "DELETE"]); if (url.pathname === MCP_PATH && req.method && MCP_METHODS.has(req.method)) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); const server = createTodoServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless mode enableJsonResponse: true, }); res.on("close", () => { transport.close(); server.close(); }); try { await server.connect(transport); await transport.handleRequest(req, res); } catch (error) { console.error("Error handling MCP request:", error); if (!res.headersSent) { res.writeHead(500).end("Internal server error"); } } return; } res.writeHead(404).end("Not Found"); }); httpServer.listen(port, () => { console.log( `Todo MCP server listening on http://localhost:${port}${MCP_PATH}` ); }); ``` This snippet also responds to `GET /` for health checks, handles CORS preflight for `/mcp`, and returns `404 Not Found` for OAuth discovery routes you are not using yet. That keeps ChatGPT from surfacing 502 errors while you iterate without authentication. ## Run locally If you're using a web framework like React, build your component into static assets so the HTML template can inline them. Usually, you can run a build command such as `npm run build` to produce a `dist` directory with your compiled assets. In this quickstart, since we're using vanilla HTML, no build step is required. Start the MCP server on `http://localhost:/mcp` from the directory that contains `server.js` (or `server.ts`). Make sure you have `"type": "module"` in your `package.json` file: ```json { "type": "module", "dependencies": { "@modelcontextprotocol/sdk": "^1.20.2", "@modelcontextprotocol/ext-apps": "^1.0.1", "zod": "^3.25.76" } } ``` Then run the server with the following command: ```bash node server.js ``` The server should print `Todo MCP server listening on http://localhost:8787/mcp` once it is ready. ### Test with MCP Inspector You can use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) to test your server locally. ```bash npx @modelcontextprotocol/inspector@latest ``` This opens the MCP Inspector interface. Select **Streamable HTTP**, enter `http://localhost:8787/mcp`, and connect to test your server and inspect its tool responses. ![MCP Inspector](https://developers.openai.com/images/apps-sdk/mcp_inspector.png) ### Expose your server to the public internet For ChatGPT to access your server during development, you need to expose it to the public internet. You can use a tool such as [ngrok](https://ngrok.com/) to open a tunnel to your local server. ```bash ngrok http ``` This will give you a public URL like `https://.ngrok.app` that you can use to access your server from ChatGPT. When you connect your MCP server in developer mode, provide the public URL with the `/mcp` path (for example, `https://.ngrok.app/mcp`). ## Connect your MCP server in ChatGPT Once your MCP server and web component work locally, connect the server in ChatGPT: 1. In [ChatGPT](https://chatgpt.com), open **Settings → Security and login** and turn on **Developer mode**. 2. Go to [ChatGPT Plugins](https://chatgpt.com/plugins) and select the plus button. 3. Paste the HTTPS + `/mcp` URL from your tunnel or deployment (for example, `https://.ngrok.app/mcp`), name the connection, provide a short description, and click **Create**. Connect an MCP server in ChatGPT 4. Open a new chat, select the plugin from the **More** menu (accessible after clicking the **+** button), and prompt the model (for example, “Add a new task to read my book”). ChatGPT will stream tool payloads so you can confirm inputs and outputs. ![Select your plugin in a conversation](https://developers.openai.com/images/apps-sdk/developer_mode_more.jpg) ## Next steps From there, you can iterate on the UI/UX, prompts, tool metadata, and the overall experience. Refresh the plugin connection after each change to the MCP server (tools, metadata, and related configuration). You can do this from the detail page at [chatgpt.com/plugins](https://chatgpt.com/plugins). When you're preparing for public distribution, review [Submit plugins](https://developers.openai.com/plugins/deploy/submission), the [Plugin guidelines](https://developers.openai.com/plugins/app-guidelines), and [Brainstorm plugin use cases](https://developers.openai.com/plugins/plan/use-case). If you're building a UI, you can also review the [UI guidelines](https://developers.openai.com/plugins/concepts/ui-guidelines). Once you understand the basics, you can [build richer UI](https://developers.openai.com/plugins/build/chatgpt-ui), [authenticate users](https://developers.openai.com/plugins/build/auth) when needed, and [manage state](https://developers.openai.com/plugins/build/chatgpt-ui#manage-state). --- # Package your plugin After building your [skills](https://developers.openai.com/plugins/build/skills) and, when needed, an [MCP server](https://developers.openai.com/plugins/build/mcp-server), assemble those parts into the plugin people will install. Packaging gives the plugin a stable identity and tells ChatGPT and Codex which skills, MCP server connections, and other resources belong together. Every plugin has a `.codex-plugin/plugin.json` manifest. Depending on the plugin's architecture, its folder can also include: - A `skills/` directory containing the workflows you built. - An `.app.json` file that references a registered MCP server connection. The filename is a compatibility identifier; the underlying primitive is the MCP server. - An `.mcp.json` file for an MCP server distributed with the plugin. - Optional assets and lifecycle hooks. UI and authentication remain part of the MCP server integration you built in the preceding steps; the plugin manifest connects that integration to the rest of the package. Public plugins are published once to the universal plugin directory shared by ChatGPT and Codex. Local and repo marketplaces are separate authoring, testing, and team-distribution sources, and their availability can vary by surface. Use `@plugin-creator` for the fastest path, or create the manifest and folder structure manually. Both approaches produce the same plugin structure. For complete public examples, inspect [Figma](https://github.com/openai/plugins/tree/main/plugins/figma), [Notion](https://github.com/openai/plugins/tree/main/plugins/notion), and [Build web apps](https://github.com/openai/plugins/tree/main/plugins/build-web-apps). ## Package with `@plugin-creator` For the fastest setup, use the built-in `@plugin-creator` skill. It scaffolds the required `.codex-plugin/plugin.json` manifest and can also generate a local marketplace entry for testing. If you already have a plugin folder, you can still use `@plugin-creator` to wire it into a local marketplace. ### Create and test a plugin locally with an MCP server You can also use the plugin-creator skill to test a plugin that includes an MCP server. The plugin still needs a local folder and manifest, and you first register the MCP server connection in ChatGPT developer mode. First, enable developer mode in ChatGPT: 1. Open [ChatGPT](https://chatgpt.com). 2. Open **Settings**. 3. Select **Security and login**. 4. Turn on **Developer mode**. Then register the MCP server in developer mode: 1. Go to [ChatGPT Plugins](https://chatgpt.com/plugins). 2. Select the plus button. 3. Complete the modal with your MCP server URL and connection details. 4. After ChatGPT creates the connection, copy its technical ID from the browser URL. It starts with `plugin_asdk_app`. Give that `plugin_asdk_app...` ID to `@plugin-creator` in Work mode in ChatGPT or `$plugin-creator` in Codex. For example, in Work mode: Plugin Creator prompt `{`@plugin-creator create a plugin for ChatGPT and Codex using my MCP server. Use plugin_asdk_app_6a4c0062f3b88191855c0a80eac5d53d and name it Acme Support. Include a personal marketplace entry so I can test it locally.`}` The plugin-creator skill will create the plugin folder, create the required `.codex-plugin/plugin.json`, and add MCP server wiring for the plugin. If you ask it to create a personal marketplace entry, the plugin appears under your local source in the Plugins Directory for testing. After the plugin-creator skill creates the plugin: 1. Review `.app.json` and confirm the registered MCP server mapping points at the correct `plugin_asdk_app...` ID. 2. Review `.codex-plugin/plugin.json` and make sure its compatibility `apps` field points to `./.app.json`. 3. Add any bundled skills under `skills/` if the plugin should include repeatable workflows alongside the MCP server. 4. If the skill created a personal marketplace entry, refresh ChatGPT and install the plugin from your local source in the Plugins Directory. Then test it in a new chat. For the manifest shape and file layout, see [Plugin structure](#plugin-structure) and [Path rules](#path-rules). ### Build your own curated plugin list A marketplace is a JSON catalog of plugins. `@plugin-creator` can generate one for a single plugin, and you can keep adding entries to that same marketplace to build your own curated list for a repo, team, or personal workflow. In Work mode or Codex in the ChatGPT desktop app, each marketplace appears as a selectable source in the Plugins Directory. Use `$REPO_ROOT/.agents/plugins/marketplace.json` for a repo-scoped list or `~/.agents/plugins/marketplace.json` for a personal list. Add one entry per plugin under `plugins[]`, point each `source.path` at the plugin folder with a `./`-prefixed path relative to the marketplace root, and set `interface.displayName` to the label you want the plugin to show in the marketplace picker. Then restart the ChatGPT desktop app. After that, open the Plugins Directory, choose your marketplace, and browse or install the plugins in that curated list. You don't need a separate marketplace per plugin. One marketplace can expose a single plugin while you are testing, then grow into a larger curated catalog as you add more plugins. ### Add a marketplace from the CLI Use `codex plugin marketplace add` to add and track a marketplace source instead of editing `config.toml` by hand. These commands support plugin authoring and catalog setup. Use the ChatGPT desktop app to install and test a local plugin. ```bash codex plugin marketplace add owner/repo codex plugin marketplace add owner/repo --ref main codex plugin marketplace add https://github.com/example/plugins.git --sparse .agents/plugins codex plugin marketplace add ./local-marketplace-root ``` Marketplace sources can be GitHub shorthand (`owner/repo` or `owner/repo@ref`), HTTP or HTTPS Git URLs, SSH Git URLs, or local marketplace root directories. Use `--ref` to pin a Git ref, and repeat `--sparse PATH` to use a sparse checkout for Git-backed marketplace repos. `--sparse` is valid only for Git marketplace sources. To inspect, refresh, or remove configured marketplaces: ```bash codex plugin marketplace list codex plugin marketplace upgrade codex plugin marketplace upgrade marketplace-name codex plugin marketplace remove marketplace-name ``` `codex plugin marketplace list` prints each marketplace Codex is considering and the root path it resolves from, including local default marketplaces and configured marketplace snapshots. ### Create a plugin manually Start with a minimal plugin that packages one skill. 1. Create a plugin folder with a manifest at `.codex-plugin/plugin.json`. ```bash mkdir -p my-first-plugin/.codex-plugin ``` `my-first-plugin/.codex-plugin/plugin.json` ```json { "name": "my-first-plugin", "version": "1.0.0", "description": "Reusable greeting workflow", "skills": "./skills/" } ``` Use a stable plugin `name` in kebab-case. Plugin hosts use it as the plugin identifier and component namespace. 2. Add a skill under `skills//SKILL.md`. ```bash mkdir -p my-first-plugin/skills/hello ``` `my-first-plugin/skills/hello/SKILL.md` ```md --- name: hello description: Greet the user with a friendly message. --- Greet the user warmly and ask how you can help. ``` 3. Add the plugin to a marketplace. Use `@plugin-creator` to generate one, or follow [Build your own curated plugin list](#build-your-own-curated-plugin-list) to wire the plugin into a local marketplace manually. From there, you can add MCP server configuration or marketplace metadata as needed. ### Install a local plugin manually Use a repo marketplace or a personal marketplace, depending on who should be able to access the plugin or curated list. Add a marketplace file at `$REPO_ROOT/.agents/plugins/marketplace.json` and store your plugins under `$REPO_ROOT/plugins/`. **Repo marketplace example** Step 1: Copy the plugin folder into `$REPO_ROOT/plugins/my-plugin`. ```bash mkdir -p ./plugins cp -R /absolute/path/to/my-plugin ./plugins/my-plugin ``` Step 2: Add or update `$REPO_ROOT/.agents/plugins/marketplace.json` so that `source.path` points to that plugin directory with a `./`-prefixed relative path: ```json { "name": "local-repo", "plugins": [ { "name": "my-plugin", "source": { "source": "local", "path": "./plugins/my-plugin" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Productivity" } ] } ``` Step 3: Restart the ChatGPT desktop app and verify that the plugin appears. Add a marketplace file at `~/.agents/plugins/marketplace.json` and store your plugins under `~/.codex/plugins/`. **Personal marketplace example** Step 1: Copy the plugin folder into `~/.codex/plugins/my-plugin`. ```bash mkdir -p ~/.codex/plugins cp -R /absolute/path/to/my-plugin ~/.codex/plugins/my-plugin ``` Step 2: Add or update `~/.agents/plugins/marketplace.json` so that the plugin entry's `source.path` points to that directory. Step 3: Restart the ChatGPT desktop app and verify that the plugin appears. The marketplace file points to the plugin location, so those directories are examples rather than fixed requirements. Codex resolves `source.path` relative to the marketplace root, not relative to the `.agents/plugins/` folder. See [Marketplace metadata](#marketplace-metadata) for the file format. After you change the plugin, update the plugin directory that your marketplace entry points to and restart the ChatGPT desktop app so the local install picks up the new files. ### Publish a local plugin to your workspace You must be a workspace admin to publish a plugin to your workspace. After you create and add a plugin, you can publish it to your ChatGPT workspace: 1. Go to [ChatGPT Plugins](https://chatgpt.com/plugins). 2. Select **Personal**. 3. Find the plugin you want to publish and open its three-dot menu. 4. Select **Publish**. 5. Specify the workspace roles that should have access to the plugin. Publishing a local plugin to your workspace doesn't publish it to the universal public Plugins Directory shared by ChatGPT and Codex. Workspace-published plugins stay within your workspace and organization boundary; accounts that aren't signed in to that workspace can't access them. Use a marketplace for repo or CLI distribution, and publish to your workspace when you want to make a plugin available to selected roles. Workspace admins can disable workspace plugin publishing through cloud-managed requirements by adding `features.plugin_sharing = false` to `requirements.toml`: ```toml features.plugin_sharing = false ``` ### Marketplace metadata If you maintain a repo marketplace, define it in `$REPO_ROOT/.agents/plugins/marketplace.json`. For a personal marketplace, use `~/.agents/plugins/marketplace.json`. A marketplace file controls plugin ordering and install policies in the ChatGPT desktop app. It can represent one plugin while you are testing or a curated list of plugins that you want ChatGPT to show together under one marketplace name. Before you add a plugin to a marketplace, make sure its `version`, publisher metadata, and install-surface copy are ready for other developers to see. ```json { "name": "local-example-plugins", "interface": { "displayName": "Local Example Plugins" }, "plugins": [ { "name": "my-plugin", "source": { "source": "local", "path": "./plugins/my-plugin" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Productivity" }, { "name": "research-helper", "source": { "source": "local", "path": "./plugins/research-helper" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Productivity" } ] } ``` - Use top-level `name` to identify the marketplace. - Use `interface.displayName` for the marketplace title shown in the ChatGPT desktop app. - Add one object per plugin under `plugins` to build a curated list that ChatGPT shows under that marketplace title. - Point each plugin entry's `source.path` at the plugin directory you want the local host to load. For repo installs, that often lives under `./plugins/`. For personal installs, a common pattern is `./.codex/plugins/`. - Keep `source.path` relative to the marketplace root, start it with `./`, and keep it inside that root. - For local entries, `source` can also be a plain string path such as `"./plugins/my-plugin"`. - Always include `policy.installation`, `policy.authentication`, and `category` on each plugin entry. - Use `policy.installation` values such as `AVAILABLE`, `INSTALLED_BY_DEFAULT`, or `NOT_AVAILABLE`. - Use `policy.authentication` to decide whether auth happens on install or first use. The marketplace controls where the local host loads the plugin from. A local `source.path` can point somewhere else if your plugin lives outside those example directories. A marketplace file can live in the repo where you are developing the plugin or in a separate marketplace repo, and one marketplace file can point to one plugin or many. Marketplace entries can also point at Git-backed plugin sources. Use `"source": "url"` when the plugin lives at the repository root, or `"source": "git-subdir"` when the plugin lives in a subdirectory: ```json { "name": "remote-helper", "source": { "source": "git-subdir", "url": "https://github.com/example/codex-plugins.git", "path": "./plugins/remote-helper", "ref": "main" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Productivity" } ``` Git-backed entries may use `ref` or `sha` selectors. If Codex can't resolve a marketplace entry's source, it skips that plugin entry instead of failing the whole marketplace. Marketplace entries can also install a plugin from a JavaScript package registry: ```json { "name": "npm-helper", "source": { "source": "npm", "package": "@example/codex-plugin", "version": "^1.2.0", "registry": "https://registry.npmjs.org" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Productivity" } ``` `package` is required and can include a registry scope. `version` is optional and accepts package versions, distribution tags, and version ranges, but not path or URL selectors. `registry` is optional and must be an HTTPS URL without embedded credentials, a query, or a fragment. Codex downloads the package without running lifecycle scripts. The `npm` CLI must be installed, and registry authentication comes from its configuration. ### How local marketplaces work A plugin marketplace is a JSON catalog of plugins. These local sources are separate from the universal public directory and support authoring, testing, and private distribution. The ChatGPT desktop app can read marketplace files from: - a repo marketplace at `$REPO_ROOT/.agents/plugins/marketplace.json` - a legacy-compatible marketplace at `$REPO_ROOT/.claude-plugin/marketplace.json` - a personal marketplace at `~/.agents/plugins/marketplace.json` You can install any plugin exposed through a marketplace. ChatGPT installs plugins into `~/.codex/plugins/cache/$MARKETPLACE_NAME/$PLUGIN_NAME/$VERSION/`. For local plugins, `$VERSION` is `local`, and ChatGPT loads the installed copy from that cache path rather than directly from the marketplace entry. You can enable or disable each plugin individually. ChatGPT stores each plugin's on or off state in `~/.codex/config.toml`. ## Package and distribute plugins ### Plugin structure Every plugin has a manifest at `.codex-plugin/plugin.json`. It can also include a `skills/` directory, a `hooks/` directory for lifecycle hooks, an `.app.json` file that maps registered MCP server connections, an `.mcp.json` file that configures bundled MCP servers, and assets used to present the plugin across supported surfaces. Only `plugin.json` belongs in `.codex-plugin/`. Keep `skills/`, `hooks/`, `assets/`, `.mcp.json`, and `.app.json` at the plugin root. Published plugins typically use a richer manifest than the minimal example that appears in quick-start scaffolds. The manifest has three jobs: - Identify the plugin. - Point to bundled components such as skills, MCP servers, or hooks. - Provide install-surface metadata such as descriptions, icons, and legal links. Here's a complete manifest example: ```json { "name": "my-plugin", "version": "0.1.0", "description": "Bundle reusable skills and MCP servers.", "author": { "name": "Your team", "email": "team@example.com", "url": "https://example.com" }, "homepage": "https://example.com/plugins/my-plugin", "repository": "https://github.com/example/my-plugin", "license": "MIT", "keywords": ["research", "crm"], "skills": "./skills/", "mcpServers": "./.mcp.json", "apps": "./.app.json", "hooks": "./hooks/hooks.json", "interface": { "displayName": "My Plugin", "shortDescription": "Reusable skills and MCP servers", "longDescription": "Distribute skills and MCP servers together.", "developerName": "Your team", "category": "Productivity", "capabilities": ["Read", "Write"], "websiteURL": "https://example.com", "privacyPolicyURL": "https://example.com/privacy", "termsOfServiceURL": "https://example.com/terms", "defaultPrompt": [ "Use My Plugin to summarize new CRM notes.", "Use My Plugin to triage new customer follow-ups." ], "brandColor": "#10A37F", "composerIcon": "./assets/icon.png", "logo": "./assets/logo.png", "screenshots": ["./assets/screenshot-1.png"] } } ``` `.codex-plugin/plugin.json` is the required entry point. The other manifest fields are optional, but published plugins commonly use them. ### Manifest fields Use the top-level fields to define package metadata and point to bundled components: - `name`, `version`, and `description` identify the plugin. - `author`, `homepage`, `repository`, `license`, and `keywords` provide publisher and discovery metadata. - `skills`, `mcpServers`, and `hooks` point to bundled components relative to the plugin root. The compatibility `apps` field points to registered MCP server mappings. - `interface` controls how install surfaces present the plugin. Use the `interface` object for install-surface metadata: - `displayName`, `shortDescription`, and `longDescription` control the title and descriptive copy. - `developerName`, `category`, and `capabilities` add publisher and capability metadata. - `websiteURL`, `privacyPolicyURL`, and `termsOfServiceURL` provide external links. - `defaultPrompt`, `brandColor`, `composerIcon`, `logo`, and `screenshots` control starter prompts and visual presentation. ### Path rules - Keep manifest paths relative to the plugin root and start them with `./`. - Store visual assets such as `composerIcon`, `logo`, and `screenshots` under `./assets/` when possible. - Use `skills` for bundled skill folders, `mcpServers` for `.mcp.json`, and `hooks` for lifecycle hooks. Use the compatibility `apps` field only for registered MCP server mappings in `.app.json`. - Enabled plugins can include lifecycle hooks alongside skills and MCP servers. - If your plugin stores hooks at `./hooks/hooks.json`, you don't need a `hooks` entry in `.codex-plugin/plugin.json`; Codex checks that default file automatically. ### Bundled MCP servers and lifecycle hooks `mcpServers` can point to an `.mcp.json` file that contains either a direct server map or a wrapped `mcp_servers` object. Direct server map: ```json { "docs": { "command": "docs-mcp", "args": ["--stdio"] } } ``` Wrapped server map: ```json { "mcp_servers": { "docs": { "command": "docs-mcp", "args": ["--stdio"] } } } ``` After installation, users can enable or disable a bundled MCP server and tune tool approval policy from their Codex config without editing the plugin. Use `plugins..mcp_servers.` for plugin-scoped MCP server policy: ```toml [plugins."my-plugin".mcp_servers.docs] enabled = true default_tools_approval_mode = "prompt" enabled_tools = ["search"] [plugins."my-plugin".mcp_servers.docs.tools.search] approval_mode = "approve" ``` When your plugin is enabled, Codex can load lifecycle hooks from your plugin alongside user, project, and managed hooks. Installing or enabling a plugin doesn't automatically trust its hooks. Plugin-bundled hooks are non-managed hooks, so Codex skips them until the user reviews and trusts the current hook definition. The default plugin hook file is `hooks/hooks.json`: ```json { "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "python3 ${PLUGIN_ROOT}/hooks/session_start.py", "statusMessage": "Loading plugin context" } ] } ] } } ``` If you define `hooks` in `.codex-plugin/plugin.json`, Codex uses that manifest entry instead of the default `hooks/hooks.json`. The manifest field can be a single path, an array of paths, an inline hooks object, or an array of inline hooks objects. ```json { "name": "repo-policy", "hooks": ["./hooks/session.json", "./hooks/tools.json"] } ``` Hook paths follow the same manifest path rules as `skills`, `apps`, and `mcpServers`: start with `./`, resolve relative to the plugin root, and stay inside the plugin root. Plugin hook commands receive the Codex-specific environment variables `PLUGIN_ROOT` and `PLUGIN_DATA`. `PLUGIN_ROOT` points to the installed plugin root, and `PLUGIN_DATA` points to the plugin's writable data directory. Codex also sets `CLAUDE_PLUGIN_ROOT` and `CLAUDE_PLUGIN_DATA` for compatibility with existing plugin hooks. Plugin hooks use the same event schema as regular hooks. See [Hooks on Learn](https://learn.chatgpt.com/docs/hooks) for supported events, inputs, outputs, trust review, and current limitations. ## Publish official public plugins To publish a plugin for public use, submit it through the plugin submission portal. After publication, the plugin is listed in the universal directory shared by ChatGPT and Codex. See [Submit plugins](https://developers.openai.com/plugins/deploy/submission) for the full review and publishing process. --- # MCP server The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) is an open specification for connecting AI clients to external tools and data. A plugin can include an MCP server when it needs to read live information, take actions, or integrate with another service. The MCP server is optional. A plugin that only provides instructions and resources can consist of [skills](https://developers.openai.com/plugins/concepts/skills) alone. ## What an MCP server provides An MCP server can expose: - **Tools:** Functions the model can call with structured inputs. - **Resources:** Data or content the client can read. - **Prompts:** Reusable prompt templates. - **Instructions:** Server-wide guidance for using its capabilities. Plugins primarily use tools. Each tool has a name, description, input schema, and optional output schema. These fields help the model decide when to call the tool and how to use its result. ## How tool calls work When a user asks for something that matches a tool: 1. The client discovers the tools exposed by the MCP server. 2. The model selects a tool and supplies arguments that match its input schema. 3. The server validates the request, performs the operation, and returns a result. 4. The model uses the result to continue the conversation. Tool results should work without custom UI. Return concise text or structured content that gives the model enough information to answer the user. An MCP server can also return an optional UI resource for clients that support [MCP Apps](https://developers.openai.com/plugins/build/chatgpt-ui#start-with-mcp-apps). ## Transport and authorization Deploy production MCP servers at stable HTTPS endpoints using the streamable HTTP transport. If tools access private data or perform actions for a user, protect the server with the authorization flow defined by the MCP specification. For protocol details, see the [MCP specification](https://modelcontextprotocol.io/specification). The [Python](https://github.com/modelcontextprotocol/python-sdk) and [TypeScript](https://github.com/modelcontextprotocol/typescript-sdk) software development kits provide server implementations and helpers. ## Next step After defining the tools your plugin needs, [build the MCP server](https://developers.openai.com/plugins/build/mcp-server). --- # Plugin architecture Plugins are the packages people discover, install, share, and publish in ChatGPT and Codex. A plugin can contain: - **Skills** that give the model instructions and resources for repeatable workflows. - **An MCP server** that exposes tools and connects to external systems. - **Both skills and an MCP server** when the model needs workflow guidance and server-backed capabilities. ChatGPT and Codex share one universal plugin directory. When you publish a public plugin, people can discover the same listing from supported surfaces in either product. Individual capabilities can still be surface-specific; for example, a plugin can include hooks that run only in Codex. An MCP server can return structured data and model-readable text without custom UI. When a task benefits from visual interaction, the server can also return a UI resource. ```text Plugin ├── Skills └── MCP server (optional) ├── Tools and structured results └── UI resources (optional) ``` Start with the smallest shape that supports your use cases. You can add an MCP server or UI later without changing the plugin's purpose. ## Skills A skill is a folder containing a `SKILL.md` file and, when needed, supporting scripts, references, templates, or assets. Skills describe when to use a workflow, which steps to follow, and what a successful result looks like. Use skills when instructions and the tools already available to the model are enough to complete the task. A plugin can package one skill or group related skills into one installable experience. For example, a meeting follow-up plugin might include separate skills for drafting a recap, identifying action items, and preparing a customer email. ## MCP servers Build an MCP server when your plugin must connect to a service, expose a controlled set of tools, authenticate users, or run behavior on infrastructure you operate. The server defines: - The tools the model can call. - Input and output schemas for those tools. - Authentication and authorization requirements. - Structured results and model-readable content. - Optional UI resources. An MCP server gives you control over which capabilities you expose. It also lets you update server behavior independently and observe requests made to your infrastructure. ## Optional UI Custom UI is not required for an MCP server. Use model responses or structured results when they communicate the outcome. Add UI when people need to inspect, compare, edit, confirm, or navigate structured information. For example, a product comparison, editable schedule, or map can benefit from a component, while a background status lookup often does not. ChatGPT supports the open [MCP Apps UI standard](https://developers.openai.com/plugins/build/chatgpt-ui#start-with-mcp-apps). Start with the shared standard, then add optional ChatGPT extensions only when the UI needs capabilities the standard does not cover. Keep tools useful without the component so the model can complete headless workflows and decide when UI adds value. ## Choose a plugin shape | Shape | Choose it when | | --------------------- | ------------------------------------------------------------------------- | | Skills only | Instructions and existing tools are enough to complete the workflow. | | MCP server only | The plugin needs MCP tools but does not need extra workflow instructions. | | Skills and MCP server | Skills should guide the model through workflows that use your MCP tools. | | MCP server with UI | Visual interaction materially improves part of an MCP-backed workflow. | After choosing a shape, [build the skills](https://developers.openai.com/plugins/build/skills) or [build the MCP server](https://developers.openai.com/plugins/build/mcp-server). Add [UI to the MCP server](https://developers.openai.com/plugins/build/chatgpt-ui) only when a use case requires it, then [package the plugin](https://developers.openai.com/plugins/build/plugins). --- # Skills Skills are folders of instructions and resources that teach ChatGPT and Codex how to complete repeatable workflows. In an MCP-backed plugin, skills complement the server by teaching the model how to combine its tools for recognizable user goals. Each skill has a `SKILL.md` file with: - A name. - A description that tells the model when to consider the skill. - Instructions for completing the workflow. - Optional references, scripts, templates, and other assets. ## How skills complement an MCP server An MCP server provides live information and controlled actions. A skill provides the workflow around those tools: when to call them, in what order, how to handle incomplete results, and what the final output should contain. For example, a skill can define how to: - Retrieve account activity and turn it into a customer briefing. - Review project data, identify risks, and draft a status update. - Combine search and fetch tools into a sourced research workflow. - Apply an organization's writing or review standards to MCP results. Keep the boundary clear: the [MCP server](https://developers.openai.com/plugins/concepts/mcp-server) provides data, authentication, authorization, and actions; the skill provides reusable instructions, examples, templates, and other resources. A skill can also work without an MCP server when the workflow needs only packaged instructions and resources. ## How skills activate The model first sees skill metadata, including the name and description. It loads the complete instructions when the user's request matches the skill or the user invokes it directly. Write descriptions around the user goal and the conditions that should trigger the workflow. Keep detailed steps and output requirements in the instruction body. ## Skills in a plugin Skills are the workflow layer of a plugin. They can: - Guide the model through tools exposed by the plugin's MCP server. - Package organization-specific procedures with reusable templates and references. - Work on their own when no live data or controlled action is required. Skills and MCP tools should have clear, complementary roles. A skill explains how to complete the workflow; an MCP server provides live information and enforces controlled actions. Continue with [Build skills](https://developers.openai.com/plugins/build/skills) to create, test, and package a skill. --- # UI guidelines ## Overview Optional plugin UI can extend what users can do without breaking the flow of conversation. Use cards, carousels, fullscreen views, and other display modes only when visual interaction improves the workflow. ![Example apps in the ChatGPT mobile interface](https://developers.openai.com/images/apps-sdk/overview.png) ## Design system To design high-quality UI that feels native to ChatGPT, you can use the [`@openai/apps-sdk-ui`](https://openai.github.io/apps-sdk-ui/) component library. It provides styling foundations with Tailwind, CSS variable design tokens, and a library of well-crafted, accessible components. The component library is optional. It provides a faster way to build components that match the ChatGPT design system. Before diving into code, start designing with our [Figma component library](https://www.figma.com/community/file/1625636989296445101) ## Display modes Display modes are the surfaces developers use to create experiences for apps in ChatGPT. They allow partners to show content and actions that feel native to conversation. Each mode is designed for a specific type of interaction, from quick confirmations to immersive workflows. Using these consistently helps experiences stay basic and predictable. ### Inline The inline display mode appears directly in the flow of the conversation. Inline surfaces currently always appear before the generated model response. Every app initially appears inline. ![Examples of inline cards and carousels in ChatGPT](https://developers.openai.com/images/apps-sdk/inline_display_mode.png) **Layout** - **Icon & tool call**: A label with the app name and icon. - **Inline display**: A lightweight display with app content embedded above the model response. - **Follow-up**: A short, model-generated response shown after the widget to suggest edits, next steps, or related actions. Avoid content that is redundant with the card. #### Inline card Lightweight, single-purpose widgets embedded directly in conversation. They provide quick confirmations, basic actions, or visual aids. **When to use** - A single action or decision (for example, confirm a booking). - Small amounts of structured data (for example, a map, order summary, or quick status). - A fully self-contained widget or tool (for example, an audio player or a score card). **Layout** ![Diagram of inline cards](https://developers.openai.com/images/apps-sdk/inline_card_layout.png) - **Title**: Include a title if your card is document-based or contains items with a parent element, like songs in a playlist. - **Expand**: Use to open a fullscreen display mode if the card contains rich media or interactivity like a map or an interactive diagram. - **Show more**: Use to disclose additional items if multiple results are presented in a list. - **Edit controls**: Provide inline support for app responses without overwhelming the conversation. - **Primary actions**: Limit to two actions, placed at bottom of card. Actions should perform either a conversation turn or a tool call. **Interaction** ![Diagram of interaction patterns for inline cards](https://developers.openai.com/images/apps-sdk/inline_card_interaction.png) Cards support basic direct interaction. - **States**: Edits made are persisted. - **Basic direct edits**: If appropriate, inline editable text allows users to make quick edits without needing to prompt the model. - **Dynamic layout**: Card layout can expand its height to match its contents up to the height of the mobile display area. **Rules of thumb** - **Limit primary actions per card**: Support up to two actions maximum, with one primary CTA and one optional secondary CTA. - **No deep navigation or multiple views within a card.** Cards should not contain multiple drill-ins, tabs, or deeper navigation. Consider splitting these into separate cards or tool actions. - **No nested scrolling**. Cards should auto fit their content and prevent internal scrolling. - **No duplicate inputs**. Don’t replicate ChatGPT features in a card. ![Examples of patterns to avoid in inline cards](https://developers.openai.com/images/apps-sdk/inline_card_rules.png) #### Inline carousel A set of cards presented side-by-side, letting users quickly scan and choose from multiple options. **When to use** - Presenting a small list of similar items (for example, restaurants, playlists, events). - Items have more visual content and metadata than will fit in basic rows. **Layout** ![Diagram of inline carousel](https://developers.openai.com/images/apps-sdk/inline_carousel_layout.png) - **Image**: Items should always include an image or visual. - **Title**: Carousel items should typically include a title to explain the content. - **Metadata**: Use metadata to show the most important and relevant information about the item in the context of the response. Avoid showing more than two lines of text. - **Badge**: Use the badge to show supporting context where appropriate. - **Actions**: Provide a single clear CTA per item whenever possible. **Rules of thumb** - Keep to **3–8 items per carousel** for readability. - Reduce metadata to the most relevant details, with three lines max. - Each card may have a single, optional CTA (for example, “Book” or “Play”). - Use consistent visual hierarchy across cards. ### fullscreen Immersive experiences that expand beyond the inline card, giving users space for multi-step workflows or deeper exploration. The ChatGPT composer remains overlaid, allowing users to continue “talking to the app” through natural conversation in the context of the fullscreen view. **When to use** - Rich tasks that cannot be reduced to a single card (for example, an interactive map with pins, a rich editing canvas, or an interactive diagram). - Browsing detailed content (for example, real estate listings, menus). **Layout** ![Diagram of fullscreen](https://developers.openai.com/images/apps-sdk/fullscreen_layout.png) - **System close**: Closes the sheet or view. - **fullscreen view**: Content area. - **Composer**: ChatGPT’s native composer, allowing the user to follow up in the context of the fullscreen view. **Interaction** ![Interaction patterns for fullscreen](https://developers.openai.com/images/apps-sdk/fullscreen_interaction_a.png) - **Chat sheet**: Maintain conversational context alongside the fullscreen surface. - **Thinking**: The composer input “shimmers” to show that a response is streaming. - **Response**: When the model completes its response, an ephemeral, truncated snippet displays above the composer. Tapping it opens the chat sheet. **Rules of thumb** - **Design your UX to work with the system composer**. The composer is always present in fullscreen, so make sure your experience supports conversational prompts that can trigger tool calls and feel natural for users. - **Use fullscreen to deepen engagement**, not to replicate your native app wholesale. ### Picture-in-picture (PiP) A persistent floating window inside ChatGPT optimized for ongoing or live sessions like games or videos. PiP remains visible while the conversation continues, and it can update dynamically in response to user prompts. **When to use** - **Activities that run in parallel with conversation**, such as a game, live collaboration, quiz, or learning session. - **Situations where the PiP widget can react to chat input**, for example continuing a game round or refreshing live data based on a user request. **Interaction** ![Interaction patterns for picture-in-picture](https://developers.openai.com/images/apps-sdk/fullscreen_interaction.png) - **Activated:** On scroll, the PiP window stays fixed to the top of the display area - **Pinned:** The PiP remains fixed until the user dismisses it or the session ends. - **Session ends:** The PiP returns to an inline position and scrolls away. **Rules of thumb** - **Ensure the PiP state can update or respond** when users interact through the system composer. - **Close PiP automatically** when the session ends. - **Do not overload PiP with controls or static content** better suited for inline or fullscreen. ## Visual design guidelines A consistent look and feel helps partner-built tools feel like a natural part of the ChatGPT platform. Visual guidelines support clarity, usability, and accessibility, while still leaving room for brand expression in the right places. These principles outline how to use color, type, spacing, and imagery in ways that preserve system clarity while giving partners space to differentiate their service. ### Why this matters Visual and UX consistency helps improve the overall user experience of using apps in ChatGPT. By following these guidelines, partners can present their tools in a way that feels consistent to users and delivers value without distraction. ### Color System-defined palettes help ensure actions and responses always feel consistent with the ChatGPT platform. Partners can add branding through accents, icons, or inline imagery, but should not redefine system colors. ![Color palette](https://developers.openai.com/images/apps-sdk/color.png) **Rules of thumb** - Use system colors for text, icons, and spatial elements like dividers. - Partner brand accents such as logos or icons should not override backgrounds or text colors. - Avoid custom gradients or patterns that break ChatGPT’s minimal look. - Use brand accent colors on primary buttons inside app display modes. ![Example color usage](https://developers.openai.com/images/apps-sdk/color_usage_1.png) _Use brand colors on accents and badges. Don't change text colors or other core component styles._ ![Example color usage](https://developers.openai.com/images/apps-sdk/color_usage_2.png) _Don't apply colors to backgrounds in text areas._ ### Typography ChatGPT uses platform-native system fonts (SF Pro on iOS, a sans-serif font on Android) to ensure readability and accessibility across devices. ![Typography](https://developers.openai.com/images/apps-sdk/typography.png) **Rules of thumb** - Always inherit the system font stack, respecting system sizing rules for headings, body text, and captions. - Use partner styling such as bold, italic, or highlights only within content areas, not for structural UI. - Limit variation in font size as much as possible, preferring body and body-small sizes. ![Example typography](https://developers.openai.com/images/apps-sdk/typography_usage.png) _Don't use custom fonts, even in full screen modes. Use system font variables wherever possible._ ### Spacing & layout Consistent margins, padding, and alignment keep partner content scannable and predictable inside conversation. ![Spacing & layout](https://developers.openai.com/images/apps-sdk/spacing.png) **Rules of thumb** - Use system grid spacing for cards, collections, and inspector panels. - Keep padding consistent and avoid cramming or edge-to-edge text. - Respect system specified corner rounds when possible to keep shapes consistent. - Maintain visual hierarchy with headline, supporting text, and CTA in a clear order. ### Icons & imagery System iconography provides visual clarity, while partner logos and images help users recognize brand context. ![Icons](https://developers.openai.com/images/apps-sdk/icons.png) **Rules of thumb** - Use either system icons or custom iconography that fits within ChatGPT's visual world—monochromatic and outlined. - Do not include your logo as part of the response. ChatGPT will always append your logo and app name before the widget is rendered. - All imagery must follow enforced aspect ratios to avoid distortion. ![Icons & imagery](https://developers.openai.com/images/apps-sdk/iconography.png) ### Accessibility Every partner experience should be usable by the widest possible audience. Accessibility should be a core consideration when you are building apps for ChatGPT. **Rules of thumb** - Text and background must maintain a minimum contrast ratio (WCAG AA). - Provide alt text for all images. - Support text resizing without breaking layouts. --- # Connect and test your plugin Test each capability before testing the complete installed plugin. If the plugin includes an MCP server, start by connecting and evaluating the server in developer mode. Then package the plugin with its skills and test the complete experience. Skills-only plugins can skip the first section. Keep your evaluation prompts and results throughout development so you can compare behavior across releases. ## Test an MCP server (optional) ### Prepare the endpoint Confirm that: - The MCP server is reachable through a public HTTPS endpoint or [Secure MCP Tunnel](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels). - A public endpoint supports streamable HTTP, typically at `/mcp`, or the tunnel can reach its configured stdio or HTTP MCP server. - Tool names, descriptions, schemas, and annotations are present. - Authentication discovery works for tools that require an account. Use Secure MCP Tunnel to connect a private MCP server in developer mode without exposing the server to the public internet. A development tunnel or another HTTPS forwarding service can also provide an endpoint for local testing. These testing options do not replace the public HTTPS endpoint required for [plugin submission](https://developers.openai.com/plugins/build/mcp-server#deploy-the-endpoint). ### Inspect the MCP server Use [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) to list and call tools directly: ```bash npx @modelcontextprotocol/inspector@latest ``` Exercise each tool with representative inputs, edge cases, missing identifiers, and empty results. Verify schema validation, authentication errors, annotations, confirmation behavior, and the model-readable result. ### Enable developer mode In ChatGPT: 1. Open **Settings**. 2. Select **Security and login**. 3. Turn on **Developer mode**. Developer mode availability can depend on account and workspace policy. ### Add the MCP server 1. Go to [ChatGPT Plugins](https://chatgpt.com/plugins). 2. Select the plus button. 3. Enter a user-facing name and description. 4. Under **Connection**, choose the connection method: - For a public endpoint, enter the MCP server URL, including the `/mcp` path. - For Secure MCP Tunnel, select **Tunnel**, then choose an available tunnel or enter its `tunnel_id`. 5. Create the connection. 6. Review the tools and metadata discovered from the server. If ChatGPT cannot connect, verify the public HTTPS endpoint with MCP Inspector, or check the tunnel's workspace association and `tunnel-client` status. Resolve transport, initialization, schema, or authentication errors before continuing. ### Check tool selection Start a new conversation and add the MCP connection from the tools menu. Create an evaluation set that includes: - Direct requests that should call a specific tool. - Indirect requests that express the same goal. - Follow-up requests that reuse identifiers from earlier results. - Write actions that require authorization or confirmation. - Unsupported requests that shouldn't call a tool. For each request, record the selected tool, arguments, result, errors, and confirmation behavior. Rerun the set whenever you change tool names, descriptions, schemas, or annotations. If the server returns optional UI, test both the component and the model-readable result. ### Test through the API Playground For raw request and response logs, open the [API Playground](https://platform.openai.com/playground): 1. Choose **Tools → Add → MCP Server**. 2. Enter the HTTPS endpoint and connect. 3. Run test prompts and inspect the request and response data. ### Refresh metadata After changing tool names, descriptions, schemas, annotations, authentication, or UI resources: 1. Deploy or restart the MCP server. 2. Open the connection at [ChatGPT Plugins](https://chatgpt.com/plugins). 3. Select **Refresh**. 4. Confirm that the advertised metadata changed. 5. Start a new conversation and rerun the affected tests. This refresh flow applies to MCP servers connected in developer mode. Published plugins with MCP use reviewed [metadata snapshots](https://developers.openai.com/plugins/deploy/submission#how-published-mcp-metadata-versions-work). To update published metadata, scan the server, submit a new version, and publish the approved version. Before packaging the plugin, confirm that: - The tool list matches the documented capabilities. - Structured results match each tool's declared output schema. - Authentication failures return useful errors. - Positive prompts select the expected tools and negative prompts don't. - Optional UI renders without console errors and restores state correctly. ## Test the complete plugin After the MCP server works—or immediately for a skills-only plugin—package and install the complete plugin from a local source: 1. [Package the plugin](https://developers.openai.com/plugins/build/plugins) with its skills, manifest, and MCP server connection when applicable. 2. Add the plugin to a local marketplace and install it from the Plugins Directory. 3. Start a new conversation with the plugin enabled. 4. Run representative requests from the plugin's use-case inventory. Create an evaluation set that includes: - Direct requests that should use a skill. - Indirect requests that express the same goal. - Follow-up requests that depend on an earlier result. - Negative requests that shouldn't use the plugin. - Boundary cases that the plugin intentionally doesn't support. For each request, check that the plugin follows the skill instructions, uses the expected resources, completes every required step, and produces a useful result. Record any missing steps, unnecessary activations, or inconsistent results. For plugins with an MCP server, also confirm that skills invoke the right tools, tool results return to the workflow, authentication works after installation, and users can complete each combined workflow from start to finish. Before submission, confirm that: - Each skill activates for the intended requests. - Similar phrasing produces consistent behavior. - Unsupported requests don't activate the plugin. - Bundled files and references resolve after installation. - The plugin's starter prompts represent workflows it can complete. - For plugins with an MCP server, bundled skills and tools work together as intended. --- # MCP server review requirements Prepare an MCP server and its optional UI for public review as part of a plugin. Submit and publish the complete plugin, including its skills, MCP server, and optional UI, through the plugin submission portal. See [Submit plugins](https://developers.openai.com/plugins/deploy/submission) for the source-of-truth submission flow and [Build an MCP server](https://developers.openai.com/plugins/build/mcp-server) for how server-backed capabilities fit into plugins. ## Prepare MCP capabilities for plugin submission Use this page for requirements that apply when a plugin includes an MCP server: organization verification, management permissions, server requirements, review snapshots, and version maintenance. When the plugin works in [developer mode](https://developers.openai.com/plugins/deploy/connect-chatgpt#test-an-mcp-server-optional), submit it for review in the [plugin submission portal](https://platform.openai.com/plugins). This page covers the MCP server and optional UI requirements for that submission. Only submit the plugin if you intend for it to be publicly available in the countries you define during submission. For private or workspace-only use, use [developer mode](https://platform.openai.com/docs/guides/developer-mode) instead. Before submitting the plugin, review the [plugin guidelines](https://developers.openai.com/plugins/app-guidelines) for MCP server and optional UI expectations, and see [Submit plugins](https://developers.openai.com/plugins/deploy/submission) for the full plugin submission, approval, and publishing flow. For the complete flow, including skills-only and MCP-backed plugins, review, approval, and publishing, see [Submit plugins](https://developers.openai.com/plugins/deploy/submission). ## Before you submit the plugin ### Organization verification Before submitting a plugin with MCP, complete identity verification in the [OpenAI Platform Dashboard](https://platform.openai.com/settings/organization/general) for the name you plan to publish under in the directory. - **If you want to publish under your own name**, complete **individual verification**. - **If you want to publish under a business name**, complete **business verification**. This is enforced during review. Publishing under an unverified individual or business name will result in rejection. ### Plugin submission permissions To create plugin drafts with MCP and submit them for review, you need the `api.apps.write` permission. To view drafts and review status in the Dashboard, you need the `api.apps.read` permission. Organization owners automatically have both permissions, and can grant them to non-owners through roles in the [OpenAI Platform Dashboard](https://platform.openai.com/settings/organization/roles). ### MCP server requirements - Your MCP server is hosted on a publicly accessible domain - You are not using a local or testing endpoint - If the server returns UI, you defined a [content security policy (CSP)](https://developers.openai.com/plugins/build/chatgpt-ui#content-security-policy-csp) that allows the exact domains the component fetches from. ### Template MCP server URLs Most plugins should submit a universal MCP server URL: a single hosted MCP endpoint that works for all users and organizations. Choose **Template** only if the plugin uses workspace-specific MCP server URLs, such as when each customer has a separate tenant, workspace, or managed MCP endpoint. We only support template-based URLs for trusted developers with whom we have an established relationship. Template submissions require two URL values: - **Example MCP Server URL:** A concrete, working MCP endpoint for review and automated checks. - **Template MCP Server URL:** The URL pattern that describes which part of the MCP endpoint changes across customer workspaces. The example MCP server URL must be a real endpoint that OpenAI can connect to during submission review. Don't enter a placeholder URL in the **Example MCP Server URL** field. Use placeholders in the **Template MCP Server URL** for the parts that a workspace admin will configure later. Placeholders must use `{name}` syntax, start with a letter, and contain only letters, numbers, or underscores. Each placeholder name must be unique. Make sure the concrete **Example MCP Server URL** matches the template pattern after replacing each placeholder with a real value. For example: ```text Example MCP Server URL: https://acme.example.com/mcp Template MCP Server URL: https://{workspace}.example.com/mcp ``` ## Submit for review If the prerequisites are met, you can submit the plugin for review from the [plugin submission portal](https://platform.openai.com/plugins). ### Start the review process In the plugin submission portal: 1. Add your MCP server details (as well as OAuth credentials if OAuth is selected), and then select **Scan Tools**. 2. Complete the required fields in the submission form and check all confirmation boxes. You will need to provide the plugin name, logo, description, company and privacy policy URLs, MCP and tool information, test prompts and responses, and localization information. If the plugin has UI, you may also provide optional screenshots. Don't provide screenshots when the plugin has no UI. 3. Select **Submit for review**. ### Metadata stored during tool scanning When you select **Scan Tools**, the dashboard imports metadata advertised by your MCP endpoint into the draft. This includes tool names, titles, and descriptions; input and output schemas; security schemes; `_meta` fields; [tool annotations](https://developers.openai.com/plugins/reference#annotations); linked UI resource metadata, including CSP settings; and MCP server `instructions`. The dashboard displays the annotation values provided by your server. Your submission justifications should explain why those server-provided annotation values match each tool's behavior. They don't override the annotations. For example, if your server advertises `readOnlyHint: false`, describing the tool as “functionally read-only” in the justification doesn't make the tool read-only. If the tool is truly read-only, update its server annotation to `readOnlyHint: true`, deploy the change, select **Scan Tools** again, verify the updated value, and then submit. Each organization can publish multiple unique plugins with MCP. For each MCP server integration, only one version may be published at a time and only one version may be in review at a time. If you need to make changes after submitting, withdraw that submission by selecting **Cancel Review** and resubmit the same version draft. _For now, projects with EU data residency cannot submit plugins with MCP servers for review. Use a project with global data residency. If you don't have one, create a new project in your current organization from the OpenAI Dashboard._ ## Review and approval Once submitted, the plugin will enter the review queue. You can review the status within the Dashboard and will receive an email notification informing you of any status changes. ### Reviews and checks We may perform automated scans or manual reviews to understand how your plugin works and whether it may conflict with our policies. ### Approval, rejection, and appeals If your plugin is approved, we will notify you by email. Once approved, you can publish it from the plugin submission portal. If your plugin is rejected or removed because of its MCP server, tools, or UI, you will receive feedback on which checks were unsuccessful. After making the necessary changes, you may resubmit the plugin for review. To appeal the decision, respond to the email you received with a clear rationale and any new information that can assist the review. ### Getting help If you have questions before, during, or after submission and the documentation does not answer them, contact OpenAI support. Include the ID shown in the plugin submission portal so the support team can identify your plugin. ### Review and approval FAQs **How long does review take?** Review timelines may vary as we continue to build and scale our processes. Please do not contact support to request expedited review, as these requests cannot be accommodated. **What are common rejection reasons and how can I resolve them?** - **We're unable to connect to your MCP server using the MCP URL and/or test credentials we were given.** - For servers requiring authentication, our review team must be able to log into a demo account with no further configuration required. - Ensure that the provided URL and credentials are correct, do not feature MFA (including requiring SMS codes, login through systems that require SMS, email or other verification schemes). - Ensure that the provided credentials can be used to log in successfully (test them outside any company networks, local area networks, or other internal networks). - Confirm that the credentials have not expired. - **One or more of your test cases did not produce correct results.** - Review all test cases carefully and rerun each one. Ensure that outputs match the expected results. Verify that there are no errors in the UI (if applicable) - for example, issues with loading content, images, or other UI issues. - Ensure that the returned textual output closely adheres to the user's request, and does not offer extraneous information that is irrelevant to the request, including personal identifiers. - Ensure that all test cases pass on the supported ChatGPT and Codex surfaces where the plugin will be available. - Compare actual outputs to precise expected behavior for each tool and fix any mismatch so results are relevant to the user's input and the plugin reliably does what it promises. - If required, in your resubmission, modify your test cases and expected responses to be clear and unambiguous. - **Your plugin returns user-related data types that are not disclosed in your privacy policy.** - Audit your MCP tool responses in developer mode by running a few realistic example requests and listing every user-related field the server returns (including nested fields and “debug” payloads). Ensure tools return only what's strictly necessary for the user's request and remove any unnecessary PII, telemetry/internal identifiers (for example, session, trace, or request IDs; timestamps; internal account IDs; or logs) and any auth secrets (tokens, keys, or passwords). - You may also consider updating your published privacy policy so it explicitly discloses all categories of personal data you collect, process, or return and why—if a field isn't truly needed, remove it rather than disclose it. - If a user identifier is truly necessary, make it explicitly requested and directly tied to the user's intent (not “looked up and echoed” by default). - **Tool hint annotations do not appear to match the tool's behavior:** - **readOnlyHint:** Set to `true` if it strictly fetches/looks up/lists/retrieves data and does not modify anything. Set to `false` if the tool can create/update/delete anything, trigger actions (send emails/messages, run jobs, enqueue tasks, write logs, start workflows), or otherwise change state. - **Destructive hint:** Set the destructive annotation to `true` if the tool can cause irreversible outcomes (deleting, overwriting, sending messages or transactions you can't undo, revoking access, or destructive admin actions), even in only select modes, through default parameters, or through indirect side effects. Ensure the justification explains what is irreversible and under what conditions, including safeguards such as confirmation steps, dry-run options, or scoping constraints. Otherwise, set it to `false`. - **openWorldHint:** Set to `true` if it can write to or change publicly visible internet state (for example, posting to social media, blogs, or forums; sending emails, SMS, or messages to external recipients; creating public tickets or issues; publishing pages; pushing code or content to public endpoints; submitting forms to third parties; or otherwise affecting systems outside a private or first-party context). Set to `false` only if it operates entirely within closed or private systems (including internal writes) and cannot change the state of the publicly visible internet. ## Publication and distribution ### Publish the plugin Once the plugin is approved, you can publish it from the [plugin submission portal](https://platform.openai.com/plugins) by selecting **Publish**. ### Discovery Once published, users can find your plugin in the universal directory shared by ChatGPT and Codex by: - Clicking a direct link to the plugin listing in the directory. - Searching for the plugin by name. Plugins that demonstrate strong real-world utility and high user satisfaction may be eligible for enhanced distribution opportunities—such as directory placement or proactive suggestions—but few plugins will receive enhanced distribution at publication. Developers cannot request enhanced distribution. ### Publication and Distribution FAQs **What happens after the plugin is approved? Will it be listed in the plugin directory automatically?** After the plugin is approved, you can choose to publish it from the [plugin submission portal](https://platform.openai.com/plugins). You must publish before it can appear in the universal plugin directory. **Why can't I see my plugin in the directory?** Plugins appear on the directory's main pages only if OpenAI selects them for enhanced distribution. To confirm that your plugin is published, search for it using the exact publication name or open its directory URL from the plugin submission portal. **What should I do if I want to issue a press release or public announcement about my plugin?** Before issuing any press releases or public announcements regarding the launch of your plugin, please first reach out to [press@openai.com](mailto:press@openai.com) to coordinate with our communications team. ## Ongoing Maintenance ### How published MCP metadata versions work Treat the metadata exposed by your MCP server as a versioned API contract for the plugin. When you scan the MCP endpoint in the plugin submission portal, OpenAI stores the discovered metadata with that draft version. Submitting the version sends that stored snapshot for review. The published plugin uses this metadata snapshot while tool calls and UI resources continue to use your live MCP server. Use this table to determine how to ship each change: | Change | Required action | When users see the change | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | Tool list, names, titles, descriptions, input or output schemas, annotations, tool security schemes, tool `_meta` fields (including UI resource references and visibility), or MCP server `instructions` | Deploy the change, create or update a draft version, scan the endpoint, submit the version for review, and publish it after approval. | After you publish the approved version. Until then, users continue to use the currently published snapshot. | | UI resource URI or linked resource metadata, including content security policy (CSP) settings | Deploy the change, create or update a draft version, scan the endpoint, submit the version for review, and publish it after approval. | After you publish the approved version. | | Backward-compatible content update served from the same published UI resource URI | Deploy the content update. You don't need to scan, submit, or publish a new version if the URI and published contract remain compatible. | After deployment. ChatGPT may continue serving cached resource contents for up to one hour. | | Server-only fix or change to live tool results, including result `_meta`, or business data | Deploy the server change. You don't need to scan, submit, or publish a new version if the change preserves the published contract. | Through your live endpoint after deployment. | | MCP server origin (`scheme`, `hostname`, or `port`) | To change the origin, create a new plugin, then complete its scan, submission, review, and publication flow. To change only the endpoint path, use the normal new-version flow. | After you publish the new plugin or approved version. | Breaking changes to the MCP server contract inside a published plugin aren't currently supported. Removing or renaming a tool, making a schema incompatible, or serving incompatible content at or removing content from a published UI resource URI can break the current version as soon as the server change deploys. Make backward-compatible updates instead: 1. Add new tools, fields, or UI resources while continuing to honor the published contracts. 2. Submit the updated metadata as a new version. 3. Publish the approved version and keep the old contracts available. You can deploy server-only fixes without submitting a new version if they preserve the published contract. If a deployment breaks the published version, roll back the server change rather than waiting for a new version to complete review. ### Submitting new versions for review Once your plugin is published, its submitted information and reviewed metadata snapshot are locked for safety. To update either, create a new draft version of the existing plugin and resubmit that version for review. Each resubmission starts a new review. In the release notes, describe what changed. The MCP server origin (`scheme`, `hostname`, or `port`) can't change between versions. To use a different origin, submit a new plugin with the new MCP server origin. You can change the endpoint path in a new version of the existing plugin. We will review the updated plugin metadata again and inform you by email and in the [plugin submission portal](https://platform.openai.com/plugins) whether the update was approved or rejected. If rejected, you may update and resubmit or appeal the decision. Once your resubmission is approved, you can publish the update, which will replace the previous plugin version. If you've made additional changes to the plugin between submission and approval and want to submit a new version for review, cancel the review from the plugin submission portal and resubmit. ### Changing published metadata versions and removing the plugin Once a plugin is published, you can change its published version from the [plugin submission portal](https://platform.openai.com/plugins) by removing the current version from publication and publishing an approved replacement. You can remove the plugin from public visibility by removing the current version from publication and not publishing an alternative version. To remove the plugin from your organization and from ChatGPT and Codex, delete it from the plugin submission portal. ### Maintenance requirements Plugins may be removed if they are inactive, unstable, or non-compliant. We may reject or remove any plugin from our services at any time and for any reason without notice, such as for legal or security concerns or policy violations. ### Ongoing Maintenance FAQs **What happens if users report my plugin as harmful or misleading?** OpenAI reviews user reports and may review or investigate your plugin, including its MCP server, tools, and UI. Plugins that violate our policies may be restricted or removed. You may appeal a removal or other enforcement action by following the appeals process described here. Regularly review and respond to feedback, and update your plugin if issues are found. **How long will updates take?** Similar to new reviews, we are unable to offer estimated times for update reviews. --- # Plugin submission errors Plugins submitted to the public directory are held to a higher standard than plugins installed in a workspace. Directory submissions must pass the shared package checks and the additional checks for listing fields, review materials, MCP tools, skills, assets, and images. This reference also covers shared package checks, such as app references, that can appear outside the submission portal. Use the error code returned during submission to find the matching requirement. Errors block submission. Warnings don't block submission, but you should review them before continuing. Non-empty values can't contain only whitespace. Supported text excludes control characters, Unicode line or paragraph separators, and unsupported invisible formatting characters. HTTPS URLs must include a host and contain no embedded credentials or unsupported characters. ## Final directory submission A package can pass upload validation and still fail final directory submission. Final submission uses stricter listing limits and checks MCP configuration, skill scans, test cases, and policy attestations. | Field | Final submission rule | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Package name | Required; at most 64 characters. Start with an ASCII letter or digit and use only ASCII letters, digits, `_`, and `-`. | | Version | Required; use a semantic version of at most 64 characters. | | Display name | Required; one line; at most 30 characters. | | Short description | Required; one line; at most 30 characters. | | Long description | Required; at most 4,000 characters. Line breaks are allowed. | | Developer name | Required; one line; at most 80 characters. | | Category | Required; choose a supported category listed in the [Listing and interface errors](#listing-and-interface-errors) section. | | Capabilities | At most 20. Each capability must be non-empty, one line, and at most 120 characters. | | Starter prompts | At most 3. Each prompt must be non-empty, unique after Unicode and whitespace normalization, one line, at most 128 characters, and contain no app `@mention`. | | URLs | Required for MCP-backed submissions; optional for skills-only submissions. Website, support, privacy policy, and terms URLs must use HTTPS and be at most 1,024 characters. | | Brand colors | Optional six-digit hex colors. The light color must have at least 2:1 contrast against white, and the dark color must have at least 2:1 contrast against `#212121`. | Every plugin submission also requires: - Passing safety and security scans for every bundled skill. Scans can take up to 2 hours. - A verified developer or business identity and all required policy attestations. For an MCP-backed plugin, final submission also requires: - Website, support, privacy policy, and terms URLs that meet the rules above. - A demo-recording URL that shows the main use cases and tools across supported platforms. - Exactly five positive test cases, three negative test cases, and release notes. - A production HTTPS MCP server URL, a completed domain-verification challenge, and a successful, current tool scan. - Explicit `readOnlyHint`, `openWorldHint`, and `destructiveHint` values and a justification for each value on every MCP tool. - Reviewer-ready demo credentials when the server uses OAuth. - Screenshots only when the MCP server provides custom UI. If you add screenshots, provide one PNG or JPEG image for every starter prompt. Each screenshot must be exactly 706 pixels wide and 400–860 pixels tall. ### Final metadata errors In these error names, `subtitle` means short description and `description` means long description. | Name | Requirement | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `submission_display_name_required` | Display name is required, non-empty, and single-line. | | `submission_display_name_too_long` | Display name must be 30 characters or fewer. | | `submission_display_name_character_unsupported` | Display name must use supported text and fit on one line. | | `submission_subtitle_required` | Short description is required, non-empty, and single-line. | | `submission_subtitle_too_long` | Short description must be 30 characters or fewer. | | `submission_subtitle_character_unsupported` | Short description must use supported text and fit on one line. | | `submission_description_required` | Long description is required and must be non-empty. Line breaks are allowed. | | `submission_description_too_long` | Long description must be 4,000 characters or fewer. | | `submission_description_character_unsupported` | Long description must use supported text. Line breaks are allowed. | | `submission_developer_name_required` | Developer name is required, non-empty, and single-line. | | `submission_developer_name_too_long` | Developer name must be 80 characters or fewer. | | `submission_developer_name_character_unsupported` | Developer name must use supported text and fit on one line. | | `plugin_capability_invalid` | Each capability must be non-empty, use supported text, fit on one line, and be 120 characters or fewer. | | `plugin_default_prompt_mention` | Starter prompts must not contain app `@mentions`. | | `plugin_default_prompt_duplicate` | Starter prompts must be unique after Unicode and whitespace normalization. | ### MCP and review errors These errors apply to MCP-backed submissions. | Name | Requirement | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `annotations_required` | Every MCP tool must set `readOnlyHint`, `openWorldHint`, and `destructiveHint` accurately. | | `justification_required` | Every MCP tool annotation must include a justification for its read-only, open-world, or destructive behavior. | | `scan_required` | MCP tools must have a successful, current scan of the production MCP server. | | `domain_verification_required` | The exact verification token must be hosted at the generated `/.well-known/openai-apps-challenge` URL on the MCP host or an allowed parent host, and **Verify Domain** must pass. | | `frame_domain_explanation_required` | Every external frame domain reported by the MCP tool scan must have an explanation of why the UI needs it and what content it provides. | | `screenshots_not_allowed` | Screenshots are allowed only when the current MCP tool scan reports a UI output template. | ## Archive errors ### Skills-only ZIP upload errors and warnings **Skills only** uploads accept a plugin manifest and bundled skills. A changed package name blocks an update; the other findings require confirmation. | Name | Requirement | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `plugin_name_mismatch` | The package name in an update must match the existing plugin name. | | `plugin_version_unchanged` | A new release must use a different manifest `version`; reusing the published version requires confirmation. | | `mcp_configuration_excluded` | Skills-only ZIP uploads must not include `mcpServers` or `.mcp.json`; MCP-backed plugins must use **With MCP**. | | `app_configuration_excluded` | Skills-only ZIP uploads must not include `apps` or `.app.json`; plugins with app content must use **With MCP**. | | `screenshot_configuration_excluded` | Skills-only ZIP uploads must not include `interface.screenshots`; screenshots require **With MCP** and custom UI. | | `claude_format_normalized` | `.claude-plugin/plugin.json` is converted to `.codex-plugin/plugin.json`, with missing interface defaults and normalized text fields added by the portal. | | `manifest_normalized` | The portal saves the normalized manifest as `.codex-plugin/plugin.json`; changed fields require confirmation. | | `developer_name_defaulted` | `author.name` and `interface.developerName` must match, or the selected verified identity is used for both after confirmation. | ### ZIP structure and limit errors | Name | Requirement | | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | | `archive_empty` | Archive must not be empty. | | `archive_too_large` | Compressed ZIP must be 100 MB or less. | | `archive_format_not_zip` | Archive must be a valid, uncorrupted ZIP file. | | `archive_member_path_empty` | Archive entry path must not be empty. | | `archive_member_path_has_outer_whitespace` | Archive entry path must not begin or end with whitespace. | | `archive_member_path_has_backslash` | Archive entry path must use `/`, not backslashes. | | `archive_member_path_absolute` | Archive entry path must be relative to the archive root. | | `archive_member_path_has_empty_segment` | Archive entry path must not contain empty segments. | | `archive_member_path_has_parent_segment` | Archive entry path must not contain `..` segments. | | `archive_member_path_too_deep` | Archive entry path must contain at most 20 segments, including the filename. | | `archive_member_path_too_long` | Archive entry path must be within the supported path-length limit. | | `archive_member_path_normalization_collision` | Archive entry paths must remain unique after case and Unicode normalization. | | `archive_member_type_unsupported` | Archive entries must be regular files or directories. | | `archive_member_too_large` | Archive entry must not exceed 100 MiB. | | `archive_member_path_duplicate` | Archive entry path must be unique. | | `archive_member_path_type_conflict` | A file path cannot also be a directory or contain another archive entry. | | `archive_too_many_entries` | Archive must not contain more than 5,000 entries. | | `archive_uncompressed_too_large` | Extracted archive must not exceed 512 MiB. | | `archive_member_unreadable` | Every archive entry must be readable, must not be encrypted, and must use supported compression. | ## Plugin root errors | Name | Requirement | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `plugin_root_missing` | The selected path must exist and be a directory containing a plugin. | | `archive_plugin_files_missing` | A skills-only ZIP must contain a supported plugin manifest and at least one valid skill at `skills//SKILL.md`. | | `plugin_root_ambiguous` | ZIP must contain exactly one plugin root, either at the archive root or in one top-level directory. | | `plugin_root_has_siblings` | A ZIP with a top-level plugin directory must not contain sibling files. | ## Plugin manifest errors | Name | Requirement | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `plugin_manifest_missing` | ZIP must contain `.codex-plugin/plugin.json`, `.agent-plugin/plugin.json`, or `.claude-plugin/plugin.json` at the root or in its single top-level directory. | | `plugin_manifest_not_file` | Plugin manifest must be a regular JSON file. | | `plugin_manifest_unreadable` | Plugin manifest must be readable UTF-8 text. | | `plugin_manifest_json_malformed` | Plugin manifest must contain valid JSON; malformed syntax is reported with a line number. | | `plugin_manifest_root_not_object` | Plugin manifest must contain a JSON object at the top level. | | `codex_manifest_parent_not_directory` | `.codex-plugin` must be a directory. | | `codex_manifest_path_not_file` | `.codex-plugin/plugin.json` must be a regular JSON file. | | `plugin_id_wrong_type` | `id` must be a string when provided. | | `plugin_id_empty` | `id` must be non-empty when provided. | | `plugin_name_missing` | `name` is required. | | `plugin_name_wrong_type` | `name` must be a string. | | `plugin_name_empty` | `name` must be non-empty. | | `plugin_name_too_long` | `name` must be 64 characters or fewer. | | `plugin_name_format` | `name` must start with an ASCII letter or digit and contain only ASCII letters, digits, `_`, or `-`. | | `plugin_version_missing` | `version` is required. | | `plugin_version_wrong_type` | `version` must be a string. | | `plugin_version_empty` | `version` must be a non-empty semantic-version string, such as `1.0.0`. | | `plugin_version_not_semver` | `version` must use semantic versioning, such as `1.0.0`. | | `plugin_version_too_long` | `version` must be 64 characters or fewer. | | `plugin_description_missing` | `description` is required. | | `plugin_description_wrong_type` | `description` must be a string. | | `plugin_description_empty` | `description` must be non-empty. | | `plugin_description_too_long` | `description` must be 1,024 characters or fewer. | | `plugin_description_character_unsupported` | `description` must use supported text. Line breaks are allowed. | | `plugin_developer_missing` | `author.name` is required. `interface.developerName` is also required and is reported separately. | | `plugin_author_wrong_type` | `author` must be an object. | | `plugin_author_name_wrong_type` | `author.name` must be a string. | | `plugin_author_name_empty` | `author.name` must be non-empty. | | `plugin_author_name_too_long` | `author.name` must be 120 characters or fewer. | | `plugin_author_name_character_unsupported` | `author.name` must use supported text. | | `plugin_author_email_wrong_type` | `author.email` must be a string when provided. | | `plugin_author_email_empty` | `author.email` must be non-empty when provided. | | `plugin_author_email_too_long` | `author.email` must be 320 characters or fewer. | | `plugin_author_email_character_unsupported` | `author.email` must use supported text. | | `plugin_author_url_wrong_type` | `author.url` must be a string when provided. | | `plugin_author_url_empty` | `author.url` must be non-empty when provided. | | `plugin_author_url_not_https` | `author.url` must be an HTTPS URL. | | `plugin_author_url_has_credentials` | `author.url` must not contain credentials. | | `plugin_author_url_too_long` | `author.url` must be 2,048 characters or fewer. | | `plugin_author_url_character_unsupported` | `author.url` must use supported text. | ## Listing and interface errors The plugin manifest's `interface` object defines the public listing shown to users. It lives in `.codex-plugin/plugin.json` and uses fields such as `displayName` and `shortDescription`: ```json { "interface": { "displayName": "Example Plugin", "shortDescription": "Summarize documents", "longDescription": "Summarize and organize documents.", "developerName": "Example", "category": "Productivity", "capabilities": ["Summarize documents"] } } ``` The four listing URLs (website, privacy policy, terms, and support) are optional for skills-only plugins and required for MCP-backed plugins. Their length limit is 2,048 characters for package validation and 1,024 characters for final directory submission. | Name | Requirement | | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `plugin_interface_wrong_type` | The plugin manifest's `interface` field must be a JSON object. | | `plugin_display_name_wrong_type` | `interface.displayName` must be a string. | | `plugin_display_name_empty` | `interface.displayName` is required and must be non-empty. | | `plugin_display_name_too_long` | `interface.displayName` must be 80 characters or fewer for package validation and 30 characters or fewer for final directory submission. | | `plugin_display_name_character_unsupported` | `interface.displayName` must use supported text. | | `plugin_short_description_missing` | `interface.shortDescription` is required, must fit on one line, and must be 240 characters or fewer for package validation and 30 characters or fewer for final directory submission. | | `plugin_short_description_wrong_type` | `interface.shortDescription` must be a string. | | `plugin_short_description_empty` | `interface.shortDescription` must be non-empty. | | `plugin_short_description_too_long` | `interface.shortDescription` must be 240 characters or fewer for package validation and 30 characters or fewer for final directory submission. | | `plugin_short_description_character_unsupported` | `interface.shortDescription` must use supported text. | | `plugin_long_description_wrong_type` | `interface.longDescription` must be a string. | | `plugin_long_description_empty` | `interface.longDescription` is required and must be non-empty. | | `plugin_long_description_too_long` | `interface.longDescription` must be 4,000 characters or fewer. | | `plugin_long_description_character_unsupported` | `interface.longDescription` must use supported text. Line breaks are allowed. | | `plugin_developer_name_wrong_type` | `interface.developerName` must be a string. | | `plugin_developer_name_empty` | `interface.developerName` is required and must be non-empty. | | `plugin_developer_name_too_long` | `interface.developerName` must be 120 characters or fewer for package validation and 80 characters or fewer for final directory submission. | | `plugin_developer_name_character_unsupported` | `interface.developerName` must use supported text. | | `plugin_category_wrong_type` | `interface.category` must be a string. | | `plugin_category_empty` | `interface.category` must be non-empty when provided; omit it to use `Other`. | | `plugin_category_unknown` | `interface.category` must be `Productivity`, `Creativity`, `Developer Tools`, `Business & Operations`, `Data & Analytics`, `Communication`, `Education & Research`, `Security`, `Finance`, `Healthcare`, `Travel`, `Entertainment`, or `Other`. | | `plugin_category_character_unsupported` | `interface.category` must use supported text. | | `plugin_capabilities_wrong_type` | `interface.capabilities` must be a list of strings. | | `plugin_capabilities_too_many` | `interface.capabilities` must contain 20 entries or fewer. | | `plugin_capability_wrong_type` | Each `interface.capabilities` entry must be a string. | | `plugin_capability_empty` | Each `interface.capabilities` entry must be non-empty when provided. | | `plugin_capability_too_long` | Each `interface.capabilities` entry must be 120 characters or fewer. | | `plugin_capability_character_unsupported` | Each `interface.capabilities` entry must use supported text. | | `plugin_website_url_wrong_type` | `interface.websiteURL` must be a string when provided. | | `plugin_website_url_empty` | `interface.websiteURL` must be non-empty when provided. | | `plugin_website_url_format` | `interface.websiteURL` must be an HTTPS URL. | | `plugin_website_url_too_long` | `interface.websiteURL` must meet the listing URL length limits. | | `plugin_privacy_policy_url_wrong_type` | `interface.privacyPolicyURL` must be a string when provided. | | `plugin_privacy_policy_url_empty` | `interface.privacyPolicyURL` must be non-empty when provided. | | `plugin_privacy_policy_url_format` | `interface.privacyPolicyURL` must be an HTTPS URL. | | `plugin_privacy_policy_url_too_long` | `interface.privacyPolicyURL` must meet the listing URL length limits. | | `plugin_terms_of_service_url_wrong_type` | `interface.termsOfServiceURL` must be a string when provided. | | `plugin_terms_of_service_url_empty` | `interface.termsOfServiceURL` must be non-empty when provided. | | `plugin_terms_of_service_url_format` | `interface.termsOfServiceURL` must be an HTTPS URL. | | `plugin_terms_of_service_url_too_long` | `interface.termsOfServiceURL` must meet the listing URL length limits. | | `plugin_support_url_wrong_type` | `interface.supportURL` must be a string when provided. | | `plugin_support_url_empty` | `interface.supportURL` must be non-empty when provided. | | `plugin_support_url_format` | `interface.supportURL` must be an HTTPS URL. | | `plugin_support_url_too_long` | `interface.supportURL` must meet the listing URL length limits. | | `plugin_homepage_wrong_type` | `homepage` must be a string when provided. | | `plugin_homepage_empty` | `homepage` must be non-empty when provided. | | `plugin_homepage_format` | `homepage` must be an HTTPS URL. | | `plugin_homepage_too_long` | `homepage` must be 2,048 characters or fewer. | | `plugin_brand_color_wrong_type` | `interface.brandColor` must be a string when provided. | | `plugin_brand_color_empty` | `interface.brandColor` must be non-empty when provided. | | `plugin_brand_color_format` | `interface.brandColor` must be a six-digit hex color, such as `#1ABCFE`. | | `plugin_brand_color_dark_wrong_type` | `interface.brandColorDark` must be a string when provided. | | `plugin_brand_color_dark_empty` | `interface.brandColorDark` must be non-empty when provided. | | `plugin_brand_color_dark_format` | `interface.brandColorDark` must be a six-digit hex color, such as `#1ABCFE`. | | `plugin_brand_color_contrast` | `interface.brandColor` must have at least 2:1 contrast against white. | | `plugin_brand_color_dark_contrast` | `interface.brandColorDark` must have at least 2:1 contrast against `#212121`. | | `plugin_default_prompt_wrong_type` | `interface.defaultPrompt` must be a string or list of strings. | | `plugin_default_prompt_too_many` | `interface.defaultPrompt` must contain at most three prompts. | | `plugin_default_prompt_entry_wrong_type` | Each `interface.defaultPrompt` entry must be a string. | | `plugin_default_prompt_empty` | Each `interface.defaultPrompt` entry must be non-empty when provided. | | `plugin_default_prompt_too_long` | Each `interface.defaultPrompt` entry must be 512 characters or fewer for package validation and 128 characters or fewer for final directory submission. | | `plugin_default_prompt_character_unsupported` | Each `interface.defaultPrompt` entry must use supported text and fit on one line. | ## Plugin content errors | Name | Requirement | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `plugin_skills_path_wrong_type` | `skills` must be a string path for the root `skills/` directory. | | `plugin_skills_path_empty` | `skills` must be a non-empty path to the root `skills/` directory when provided. | | `plugin_skills_path_unsupported` | `skills` must resolve to the root `skills/` directory. | | `plugin_skills_directory_missing` | A declared root `skills/` directory must exist. | | `plugin_skills_path_not_directory` | Root `skills/` must be a directory when declared. | | `plugin_apps_path_wrong_type` | `apps` must be a string path for the root `.app.json`. | | `plugin_apps_path_empty` | `apps` must be a non-empty path to the root `.app.json` when provided. | | `plugin_apps_path_unsupported` | `apps` must resolve to the root `.app.json`. | | `plugin_apps_file_missing` | A declared root `.app.json` file must exist. | | `plugin_apps_path_not_file` | Root `.app.json` must be a regular file when declared. | | `plugin_runtime_surface_missing` | A skills-only ZIP must contain at least one valid skill at `skills//SKILL.md`; app and MCP references don't satisfy this requirement. | ## Skill errors | Name | Requirement | | ----------------------------------------- | --------------------------------------------------------------------------------------------- | | `skill_manifest_missing` | Skill must contain a `SKILL.md` file. | | `skill_bundle_too_large` | Each compressed skill bundle must be within the MiB limit reported in the error. | | `skill_directory_hidden` | Skill directory names must not begin with `.`. | | `skill_manifest_nested` | Each skill directory must be an immediate child of `skills/`. | | `skill_manifest_not_regular_file` | `SKILL.md` must be a regular file. | | `skill_manifest_unreadable` | `SKILL.md` must be readable. | | `skill_manifest_invalid_utf8` | `SKILL.md` must contain valid UTF-8. | | `skill_frontmatter_missing` | `SKILL.md` must start with YAML front matter between `---` lines. | | `skill_frontmatter_unclosed` | `SKILL.md` YAML front matter must end with `---`. | | `skill_frontmatter_yaml_malformed` | `SKILL.md` front matter must contain valid YAML. | | `skill_frontmatter_wrong_type` | `SKILL.md` front matter must contain a YAML mapping. | | `skill_name_missing` | `name` is required and must not be empty. | | `skill_name_wrong_type` | `name` must be a string. | | `skill_name_empty` | `name` must be non-empty. | | `skill_name_character_unsupported` | Skill front matter `name` must use supported text. | | `skill_description_missing` | `description` is required and must not be empty. | | `skill_description_wrong_type` | `description` must be a string. | | `skill_description_empty` | `description` must be non-empty. | | `skill_description_too_long` | `description` must be 1,024 characters or fewer. | | `skill_description_character_unsupported` | Skill front matter `description` must use supported text. | | `skill_body_empty` | Skill instructions must not be empty. | | `skill_identity_too_long` | The combined plugin and skill name (`plugin-name:skill-name`) must be 64 characters or fewer. | | `skill_identity_duplicate` | Each skill `name` must be unique within the plugin. | ## Skill agent metadata errors A bundled skill can define its own `interface` in `skills//agents/openai.yaml`. This controls how the skill appears to users and is separate from the plugin manifest's `interface`. Skill interface fields use snake_case: ```yaml interface: display_name: "Summarize documents" short_description: "Summarize a document" icon_small: "./assets/icon.png" default_prompt: "Summarize the selected document." ``` | Name | Requirement | | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `skill_agent_not_regular_file` | `agents/openai.yaml` must be a regular file. | | `skill_agent_unreadable` | `agents/openai.yaml` must be readable. | | `skill_agent_invalid_utf8` | `agents/openai.yaml` must contain valid UTF-8. | | `skill_agent_yaml_malformed` | `agents/openai.yaml` must contain valid YAML. | | `skill_agent_top_level_wrong_type` | `agents/openai.yaml` must contain a YAML mapping at the top level. | | `skill_agent_interface_missing` | `interface` is required in `agents/openai.yaml` when that file is included. | | `skill_agent_interface_wrong_type` | `interface` in `agents/openai.yaml` must be a YAML mapping. | | `skill_agent_display_name_missing` | `interface.display_name` is required and must not be empty. | | `skill_agent_display_name_wrong_type` | `interface.display_name` must be a string. | | `skill_agent_display_name_empty` | `interface.display_name` must not be empty. | | `skill_agent_short_description_missing` | `interface.short_description` is required and must not be empty. | | `skill_agent_short_description_wrong_type` | `interface.short_description` must be a string. | | `skill_agent_short_description_empty` | `interface.short_description` must not be empty. | | `skill_agent_icon_small_wrong_type` | `interface.icon_small` must be a non-empty relative file path when provided. | | `skill_agent_icon_small_empty` | `interface.icon_small` must be a non-empty relative file path when provided, such as `assets/icon.png`. | | `skill_agent_icon_large_wrong_type` | `interface.icon_large` must be a non-empty relative file path when provided. | | `skill_agent_icon_large_empty` | `interface.icon_large` must be a non-empty relative file path when provided, such as `assets/icon.png`. | | `skill_agent_brand_color_wrong_type` | `interface.brand_color` must be a string when provided. | | `skill_agent_brand_color_empty` | `interface.brand_color` must be a non-empty six-digit hex color when provided, such as `#1ABCFE`. | | `skill_agent_brand_color_format` | `interface.brand_color` must be a six-digit hex color, such as `#1ABCFE`. | | `skill_agent_default_prompt_wrong_type` | `interface.default_prompt` must be a string when provided. | | `skill_agent_default_prompt_empty` | `interface.default_prompt` must be non-empty when provided. | | `skill_agent_policy_wrong_type` | `policy` must be a YAML mapping when provided. | | `skill_agent_allow_implicit_invocation_wrong_type` | `policy` may contain only `products` and `allow_implicit_invocation`. `products` must contain `CHAT`, `CODEX`, or both, and `allow_implicit_invocation` must be `true` or `false`. | | `skill_agent_dependencies_wrong_type` | `dependencies` must be a YAML mapping; only `tools` is supported. | | `skill_agent_dependency_unsupported` | Only `dependencies.tools` is supported in `agents/openai.yaml`. | ## Asset path errors | Name | Requirement | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `declared_asset_path_wrong_type` | The named asset field must be a file path string. | | `declared_asset_path_empty` | The named asset field must not be empty. | | `declared_asset_path_has_outer_whitespace` | The named asset field must not begin or end with whitespace. | | `declared_asset_path_has_control_character` | The named asset field must not contain characters U+0000–U+001F or U+007F. | | `branding_asset_path_missing_root_prefix` | The named asset field must start with `./`. | | `declared_asset_path_unsafe` | The named asset field must be a relative path inside the plugin and must not contain an absolute path, drive prefix, or `..` traversal segment. | | `declared_asset_path_outside_package` | The named asset field must reference a file inside the plugin. | | `declared_asset_file_missing` | The named asset field references a file that does not exist. | | `declared_asset_not_regular_file` | The named asset field must reference a file, not a directory or special file. | ## Image errors Directory branding images must use a supported file type and meet the size and dimension limits below. These rules apply to packaged branding assets; starter-prompt screenshots use the separate portal limits listed above. | Name | Requirement | | ----------------------------------------- | -------------------------------------------------------------------------- | | `plugin_logo_path_missing` | `interface.logo` is required and must reference a square image. | | `plugin_composer_icon_path_missing` | `interface.composerIcon` is required and must reference a square image. | | `image_file_unreadable` | Image file must be readable. | | `image_file_too_large` | Image must not exceed 5 MiB. | | `image_file_format_unsupported` | Image filename must end in `.png`, `.jpg`, `.jpeg`, `.webp`, or `.svg`. | | `raster_image_decode_failed` | Raster image must be a PNG, JPEG, or WebP file that can be decoded safely. | | `raster_image_extension_content_mismatch` | Image filename extension must match the detected image format. | | `raster_image_not_square` | Image must be square. | | `raster_image_dimensions_too_small` | Image dimensions must be at least 48×48 pixels. | | `raster_image_dimensions_too_large` | Image dimensions must not exceed 4,096×4,096 pixels. | | `svg_xml_malformed` | SVG must contain valid UTF-8 XML. | | `svg_root_element_invalid` | SVG root element must be ``. | | `svg_dimensions_missing` | SVG must define a numeric `viewBox` or numeric `width` and `height`. | | `svg_dimensions_not_numeric` | SVG dimensions must be numeric and omit units and percentages. | | `svg_dimensions_not_positive` | SVG width and height must be positive finite numbers. | | `svg_dimensions_not_square` | SVG dimensions must be square. | | `svg_dimensions_too_small` | SVG dimensions must be at least 48×48 pixels. | ## App reference errors The shared package checks validate `.app.json` when a plugin references apps. The submission portal doesn't publish references to existing ChatGPT apps: a **Skills only** upload removes `.app.json`, and an MCP-backed submission must use **With MCP** and submit the MCP server directly. For local or workspace packages, the top-level `apps` object maps each app alias to an app entry. | Name | Requirement | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `app_manifest_unreadable` | `.app.json` must be readable UTF-8 text. | | `app_manifest_json_malformed` | `.app.json` contains malformed JSON near the reported line. | | `app_manifest_wrong_type` | `.app.json` must contain a JSON object at the top level. | | `app_entries_missing` | `apps` is required. | | `app_entries_wrong_type` | `apps` must be an object. | | `app_entry_wrong_type` | Each app entry must be an object. | | `app_id_missing` | Each app entry's `id` is required. | | `app_id_wrong_type` | Each app entry's `id` must be a string. | | `app_id_format` | Each app entry's `id` must begin with `asdk_app_`, `connector_`, or `templated_apps_`, followed by a letter or digit and then only letters, digits, `_`, or `-`. | | `app_entry_optional_wrong_type` | Each app entry's `optional` value must be `true` or `false` when provided. | | `app_entry_required_wrong_type` | Each app entry's `required` value must be `true` or `false` when provided. | | `app_not_eligible` | For a local or workspace package, each referenced app must be a released public Codex app, available connector, or released app template. Directory submissions must use **With MCP** and submit the MCP server directly. | ## Package warnings These warnings identify package content that validation ignores or normalizes. They don't block submission. Review them to confirm the submitted plugin contains the expected files and settings. | Name | Requirement | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `duplicate_app_reference` | Each app ID in `.app.json` must be referenced once; duplicate references are treated as one app. | | `undeclared_app_manifest_ignored` | A root `.app.json` is imported only when the plugin-manifest `apps` field is set to `./.app.json`. | | `undeclared_mcp_manifest_ignored` | A root `.mcp.json` is imported only when the plugin-manifest `mcpServers` field is set to `./.mcp.json`. | | `skill_file_ignored` | Files directly under `skills/` aren't imported as skills; each skill must be in a directory containing `SKILL.md`. | | `skill_symlink_ignored` | Symbolic links directly under `skills/` aren't imported as skills; each skill must be a real directory containing `SKILL.md`. | | `skill_frontmatter_adjusted` | Skill `name` and `description` are normalized during import by trimming outer whitespace and collapsing internal whitespace. | | `skill_metadata_ignored` | Skill interface settings must use the `interface` mapping in `agents/openai.yaml`; `metadata` in `SKILL.md` doesn't configure the interface. | ## Next steps After resolving all validation errors, return to [Submit plugins](https://developers.openai.com/plugins/deploy/submission) to complete the submission. --- # Submit plugins Use the plugin submission portal to submit a plugin for review when you're ready to publish it for public use. If you're migrating an existing Claude Code plugin or connector, first review [Submit your Claude Code plugin to OpenAI](https://developers.openai.com/plugins/guides/submit-claude-plugin) to see what you need to change before starting the submission. If the portal returns an error code, use the [submission error reference](https://developers.openai.com/plugins/deploy/submission-errors) to find the matching requirement. A plugin can contain skills, an MCP server, or both. You can submit: - A skills-only plugin that packages reusable workflows. - An MCP-only plugin. Custom UI is optional. - A plugin that combines an MCP server with uploaded or MCP-imported skills. The submission form collects listing information, MCP server details, skills, starter prompts, test cases, country availability, and policy attestations. Which fields you complete depends on whether the plugin includes skills, an MCP server, or both. For local development, packaging, and marketplace setup, see [Build plugins](https://developers.openai.com/plugins/build/plugins). For server-backed capabilities, see [Build an MCP server](https://developers.openai.com/plugins/build/mcp-server). ## Before you submit ### Submit the MCP server, not an existing integration reference You cannot submit a plugin that references an existing, already-published integration. If your plugin includes an MCP server that already exists in ChatGPT or Codex, submit that server from scratch through the portal as a new MCP-backed plugin submission. The portal scans that MCP server, validates the tool metadata, and uses the submitted server details during review. ### Get plugin submission access You need an organization role with plugin submission write access before you can create or submit plugin drafts. The Platform currently labels this permission **Apps Management**. 1. Open [OpenAI Platform roles settings](https://platform.openai.com/settings/organization/people/roles). 2. Select the organization that owns the plugin. 3. Open the role assigned to the submitter, or create a new role. 4. In the role permissions, set **Apps Management** to **Write**. 5. Save the role and assign it to each person who needs to create, edit, or submit plugin drafts. 6. Reload the [plugin submission portal](https://platform.openai.com/plugins). Apps Management write permission in Platform role settings Organization owners already have these permissions. Non-owner submitters need write access to create or submit drafts, and read access to view drafts and review status. ### Verify your developer or business identity Every public submission must use a verified developer or business identity in the OpenAI Platform. Reviewers use this identity to confirm the submission matches the name, website, support contact, privacy policy, and terms in your public listing. To verify an identity: 1. Sign in to the [OpenAI Platform](https://platform.openai.com). 2. Select the organization that will publish the plugin. 3. Open [organization settings](https://platform.openai.com/settings/organization/general). 4. Complete **individual verification** if you will publish under your own name, or **business verification** if you will publish under a company name. 5. Return to the plugin submission form and select the verified identity in the **Developer Identity** field. Reviewers may reject submissions that use an unverified or mismatched publisher identity. See the [organization verification requirements](https://developers.openai.com/plugins/deploy/app-review#organization-verification) for the underlying review rule. If the Platform shows that the developer or business identity is verified but the plugin submission form does not recognize it, check that you are submitting from the same organization and project where the identity was verified. The submitter also needs **Apps Management** write access for that organization. Ask an organization owner or admin to update the role assigned to the person submitting, then reload the plugin submission portal. ### Prepare required materials Before opening the form, collect: | Material | What to prepare | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Listing details | Plugin name, short description, long description, logo, category, website, support URL, privacy policy URL, and terms URL. | | Developer identity | Verified individual or business identity in the OpenAI Platform. | | MCP server | For plugins with MCP: public MCP server URL, domain verification access, authentication details, demo credentials if needed, content security policy, and accurate tool metadata. | | Tool annotations | For plugins with MCP: `readOnlyHint`, `openWorldHint`, and `destructiveHint` values for every MCP tool. | | Skills | For skills plugins: a final skill bundle or an MCP server that exposes static skills for **Scan Tools** to import. | | Prompts | Starter prompts that show useful, realistic workflows. | | Test cases | Five positive test cases and three negative test cases with clear expected behavior. | | Availability | Countries or regions where the plugin should be available. | | Release notes | A short summary of what you are submitting and what changed since any prior version. | ## Create a plugin submission 1. Open the [plugin submission portal](https://platform.openai.com/plugins). 2. Select **Create plugin**. 3. Choose the submission type: - **Skills only** for a plugin that only packages skills. - **With MCP** for an MCP-only plugin. - **With MCP** for a plugin that combines an MCP server with uploaded or MCP-imported skills. The portal saves the submission as a draft while you complete the form. ## Complete the form ### Info Complete the public listing and publisher fields: - **Plugin name:** Use the customer-facing product or workflow name. - **Descriptions:** Explain what the plugin helps users do. Keep the short description concise and use the long description for workflow details. - **Developer Identity:** Select the verified individual or business identity for the publisher. - **Logo and category:** Use production-ready brand assets. - **Website, support, privacy, and terms URLs:** Use public URLs that match the publisher and disclose relevant data handling. Info tab with publisher and policy URLs filled out Review your MCP responses against your privacy policy before you submit. Remove unnecessary personal data, auth secrets, debug payloads, internal identifiers, and undisclosed user-related fields from tool responses. ### MCP For submissions with MCP: 1. Choose the MCP server URL type: - Choose **Universal** when one fixed MCP server URL works for all users and organizations. - Choose **Template** only when OpenAI has approved a workspace-specific URL, such as when each customer has a separate tenant, workspace, or managed MCP endpoint. 2. Enter the required URL: - For **Universal**, enter the production **MCP Server URL**. - For **Template**, enter both an **Example MCP Server URL** and a **Template MCP Server URL**. The example must be a concrete, working endpoint that matches the template and works with the submitted test credentials. 3. Configure authentication and provide reviewer-ready demo credentials if the server requires sign-in. 4. Define a content security policy that allows the exact domains your UI fetches from. 5. Complete domain verification if the portal shows a **Domain not verified** challenge. Use an HTTPS origin on the MCP host name or a parent host name, and host the exact token at `/.well-known/openai-apps-challenge`. 6. Select **Scan Tools**. 7. Review the discovered tools, imported skills, domains, validation output, and tool metadata. 8. Fix server, skill, or metadata issues, deploy the fix, then scan again. MCP tab after scanning a demo MCP server with metadata recommendations To support workspace domain restrictions for a plugin that uses OAuth, configure the authorization server to advertise a UserInfo Endpoint that returns the user's `email` claim and `email_verified: true`. Before submitting, confirm that the provider also advertises and enables the `openid` and `email` scopes. You can also return these claims in an ID token, but the UserInfo Endpoint is required for workspace domain restrictions. If the provider doesn't support these requirements, work with the provider to add support. See [Support workspace domain restrictions](https://developers.openai.com/plugins/build/auth#support-workspace-domain-restrictions). #### Template MCP server URLs Most plugins should use **Universal**. Template MCP server URLs are available only in limited cases where different groups of users or data require different MCP server URLs. OpenAI supports template-based URLs only for trusted developers with whom we have an established relationship. If OpenAI has not approved your use of a template URL, submit a universal URL. In the **Template MCP Server URL**, use `{name}` placeholders for the parts that a workspace admin configures. Placeholder names must start with a letter, contain only letters, numbers, or underscores, and be unique within the URL. The **Example MCP Server URL** must replace each placeholder with a real value. For example: ```text Example MCP Server URL: https://acme.example.com/mcp Template MCP Server URL: https://{workspace}.example.com/mcp ``` The example URL must be publicly accessible during review. Don't enter a placeholder URL in the **Example MCP Server URL** field. For the complete MCP review requirements, see [Template MCP server URLs](https://developers.openai.com/plugins/deploy/app-review#template-mcp-server-urls). Do not enter an existing integration ID or try to point the portal at an existing published integration. The submission must provide the MCP server URL and review materials directly, even when that server backs an integration already published in ChatGPT or Codex. #### Domain verification Plugins with MCP must verify control of the domain that hosts the server. When the portal shows a domain verification challenge, place the exact verification token at the generated well-known URL: ```text https:///.well-known/openai-apps-challenge ``` The challenge endpoint must return only that plugin's verification token. Do not return JSON, a list of tokens, or multiple tokens from the same URL. The **Challenge Base URL** is an optional HTTPS origin that tells the portal where to check the token. It must be the MCP host name or a parent host name. Paths are ignored. For example, if the MCP server URL is `https://api.example.com/mcp`, the default challenge URL is `https://api.example.com/.well-known/openai-apps-challenge`, and `https://example.com` can be used as a parent-origin challenge base if you can host the token there. If two plugins with MCP share the same host name but differ only by path, they also share the same default challenge URL. You cannot verify them separately by putting different tenant paths in the Challenge Base URL, because the path is ignored. Use a parent origin that can host the new token, give the MCP server a distinct host name, or work with OpenAI support if neither hosting option is possible. If another plugin with MCP already uses the same host name, do not replace its existing challenge token unless that plugin no longer needs it. Use an allowed parent-origin Challenge Base URL or a distinct MCP host name for the new submission. Every tool should have clear names, descriptions, schemas, and output structure. Add output schemas when they help reviewers and models understand what the tool returns. Set tool annotations to match each tool's real behavior: | Annotation | Use it when | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `readOnlyHint` | Set to `true` only when the tool fetches, looks up, lists, retrieves, previews, or computes information and doesn't change anything. Set to `false` if the tool can create, update, delete, send, enqueue, run jobs, start workflows, write logs, or otherwise change state. | | `openWorldHint` | For write tools, set to `true` if the tool can change publicly visible internet state, such as posting online, sending external messages, publishing content, pushing code, or submitting forms to third parties. Set to `false` only if the tool operates entirely within closed or private systems and can't change publicly visible internet state. | | `destructiveHint` | For write tools, set to `true` if the tool can delete, overwrite, revoke access, send messages or transactions that can't be undone, or cause another irreversible side effect. Otherwise, set it to `false`. | For implementation details, see [tool annotations and elicitation](https://developers.openai.com/plugins/build/mcp-server#tool-annotations-and-elicitation). For review expectations, see the [tool hint rejection guidance](https://developers.openai.com/plugins/deploy/app-review#review-and-approval-faqs). ### Skills Add skills to the draft in either of these ways: - Upload the final skill bundle for skills-only or skills-plus-MCP submissions. - For submissions with MCP, import static skills from the MCP server. When you select **Scan Tools**, OpenAI imports them into the draft. Use the same file tree and instructions you tested locally. To import skills from MCP, follow the [draft skills extension and static resource manifest](https://developers.openai.com/plugins/build/mcp-server#import-skills-from-the-mcp-server). Skills tab ready for a skill bundle upload Each skill should include: - A clear `SKILL.md` with trigger conditions and task instructions. - Any referenced scripts, templates, or assets. - Minimal, scoped instructions that fit the plugin's purpose. OpenAI scans uploaded and MCP-imported skills for policy compliance and security risks, including sensitive information, unnecessary access requests, and instructions that may conflict with safe or expected plugin behavior. Skills must follow the same standards as the rest of the plugin and may block submission or require remediation if they fail automated scanning. OpenAI imports skills from MCP as a submission-time snapshot. Published plugins do not update those skills live. After changing a skill on the server, select **Scan Tools** again and review the updated skills before submitting a new plugin version. To remove every MCP-imported skill, keep the skills extension enabled, return `{ "skills": [] }` without a `nextCursor`, and scan again. Removing the extension or returning a response that does not pass validation preserves the previous snapshot. ### Prompts Add starter prompts that show the plugin's highest-value workflows. Good prompts are specific enough to show when to use the plugin, but general enough that users can adapt them. Examples: - "Investigate checkout errors from the last release and summarize likely root causes." - "Create a P1 incident brief from the latest support tickets and related deploys." - "Review unsuccessful deployment logs and recommend the next debugging step." Prompts tab with example starter prompts ### Testing Submit at least five positive test cases and three negative test cases. For each positive test case, include: - User prompt. - Expected tool, skill, or workflow behavior. - Expected result shape. - Test account or fixture data required to reproduce it. For each negative test case, include: - User prompt or scenario. - Expected refusal, clarification, or safe fallback behavior. - Why the plugin shouldn't complete the requested action. Use test cases that reviewers can run without internal context. If your plugin requires authentication, make sure the provided demo credentials can complete each test without MFA, SMS, email confirmation, or private-network access. Testing tab with a test case for the roll_dice tool ### Global Choose the countries or regions where the plugin should be available. Only select locations where the publisher, product, support process, and legal terms are ready for users. Global tab for country and region availability ### Submit Review the full draft before submitting. In the release notes, summarize: - What the plugin does. - Whether this is an initial submission or an update. - What changed since the prior submitted version, if any. - Anything reviewers should know about test credentials, expected data, or setup. Complete the policy attestations only after confirming the listing, server, skills, prompts, tests, and availability are accurate. Then select **Submit for Review**. Submit tab with release notes and final attestations ## Public publishing flow Submitting a plugin starts review; it doesn't publish the plugin immediately. For public availability, the flow is: 1. Submit the plugin through the plugin submission portal. 2. OpenAI reviews the submission. Review timelines may vary as OpenAI builds and scales the review process. 3. After OpenAI approves the plugin, the developer chooses when to publish it and publishes it from the portal. 4. After publication, the plugin appears in the universal Plugins Directory shared by ChatGPT and Codex. MCP-only, skills-only, and skills-plus-MCP plugins all appear in the Plugins Directory. ### How published MCP metadata versions work Plugins with MCP publish reviewed metadata and skill snapshots. To change a snapshot, scan the MCP server, submit a new version for review, and publish the approved version. For metadata-specific maintenance rules, see [MCP server review requirements](https://developers.openai.com/plugins/deploy/app-review#how-published-mcp-metadata-versions-work). ## Final checklist Before submitting, confirm: - The submitter has **Apps Management** write access. - The publisher has a verified developer or business identity. - The MCP server uses a public, production URL. - Plugins with UI define a content security policy for the exact domains the component fetches from. - Reviewer credentials work without MFA, email confirmation, SMS confirmation, or private-network access. - Tool names, descriptions, schemas, and annotations match actual behavior. - Every tool has accurate `readOnlyHint`, `openWorldHint`, and `destructiveHint` values. - Tool responses don't include unnecessary personal data, auth secrets, debug payloads, internal identifiers, or undisclosed user-related fields. - You tested the skills locally with the final file tree. - MCP-imported skills match the latest **Scan Tools** snapshot. - Starter prompts show realistic user workflows. - The submission includes five positive and three negative test cases. - Privacy policy, terms, support, and website URLs are public and match the publisher identity. --- # Troubleshooting ## How to triage issues When something goes wrong—components failing to render, discovery missing prompts, auth loops—start by isolating which layer is responsible: server, component, or ChatGPT client. The checklist below covers the most common problems and how to resolve them. Server, tool, and discovery checks apply to plugins in ChatGPT and Codex. UI, widget state, and client-authentication checks on this page describe ChatGPT behavior. ## Server-side issues - **No tools listed:** Confirm your server is running and that you are connecting to the `/mcp` endpoint. If you changed ports, update the connector URL and restart MCP Inspector. - **Structured content only, no component:** Confirm the tool descriptor sets `_meta.ui.resourceUri` to a registered HTML resource with `mimeType: "text/html;profile=mcp-app"` (ChatGPT honors `_meta["openai/outputTemplate"]` as an optional compatibility alias), and that the resource loads without CSP errors. - **Schema mismatch errors:** Ensure your Python or TypeScript models match the schema advertised in `outputSchema`. Regenerate types after making changes. - **Slow responses:** Components feel sluggish when tool calls take longer than a few hundred milliseconds. Profile server calls and cache results when possible. ## Widget issues - **Widget fails to load:** Open the browser console (or MCP Inspector logs) for CSP violations or missing bundles. Make sure the HTML contains your compiled JavaScript and that the bundle contains all dependencies. - **Drag-and-drop or editing doesn't persist:** If you rely on ChatGPT's widget-state persistence, call `window.openai.setWidgetState` after each update and restore state from `window.openai.widgetState` on mount. - **Layout problems on mobile:** If you rely on ChatGPT layout signals, inspect `window.openai.displayMode` and `window.openai.maxHeight` to adjust layout. Avoid fixed heights or hover-only actions. ## Discovery and entry-point issues - **Tool never triggers:** Revisit your metadata. Rewrite descriptions with “Use this when…” phrasing, update starter prompts, and retest using your golden prompt set. - **Wrong tool selected:** Add clarifying details to similar tools or specify disallowed scenarios in the description. Consider splitting large tools into smaller, purpose-built ones. - **Launcher ranking feels off:** Refresh your directory metadata and ensure the plugin icon and descriptions match what users expect. ## Authentication problems - **401 errors:** Include a `WWW-Authenticate` header in the error response so ChatGPT knows to start the OAuth flow again. Double-check issuer URLs and audience claims. - **Client registration fails:** If you use CIMD, confirm your authorization server metadata includes `client_id_metadata_document_supported: true` and can fetch ChatGPT's client metadata document. For `private_key_jwt`, confirm your authorization server can fetch ChatGPT's public JWKS and check the signed client assertion. If you use DCR, confirm your authorization server exposes `registration_endpoint` and that newly created clients have at least one login connection enabled. - **An existing connector returns `invalid_client`:** Confirm that the dynamically registered OAuth client still exists and that your authorization server accepts its client secret, if it has one. ChatGPT reuses these credentials, so restore them instead of creating a new client. An expired access token requires a different fix. ## Deployment problems - **ngrok tunnel times out:** Restart the tunnel and verify your local server is running before sharing the URL. For production, use a stable hosting provider with health checks. - **Streaming breaks behind proxies:** Ensure your load balancer or CDN allows server-sent events or streaming HTTP responses without buffering. ## When to escalate If you have validated the points above and the issue persists: 1. Collect logs (server, component console, ChatGPT tool call transcript) and screenshots. 2. Note the prompt you issued and any confirmation messages. 3. Share the details with your OpenAI partner contact so they can reproduce the issue internally. A crisp troubleshooting log shortens turnaround time and keeps your connector reliable for users. --- # Local services Get Quote conversion spec Local services Get Quote conversion plugins in ChatGPT are currently in beta and being tested with approved partners. To apply for access, complete the [ChatGPT merchants form](https://chatgpt.com/merchants/). ## Purpose ChatGPT can directly invoke partner plugins for high-intent local services use cases, such as requesting a quote. To enable this flow, provide local business data that identifies your service, and expose an MCP tool that opens your quote-request widget. If you want to build a plugin that follows this spec, apply for access through the [ChatGPT merchants form](https://chatgpt.com/merchants/). ## User experience When a user searches for an eligible local business, the business card or sidebar can display a **Get Quote** button. Selecting the button opens the provider's quote-request widget in a modal in ChatGPT. ChatGPT displays the button only when the business has an eligible service provider and that provider has a configured partner plugin. ## Required contract Register an MCP tool named `request_service` with `ui://widget/request-service.html` as its widget resource. ChatGPT launches the widget in `modal` display mode and sends the provider's business ID when a user selects **Get Quote**: ```ts const launcherTool = { name: "request_service", _meta: { ui: { resourceUri: "ui://widget/request-service.html", }, }, }; const launcherInput = { business_id: "biz_123", }; ``` Set `_meta["openai/widgetAccessible"] = true` on any helper tool that the widget calls directly. This metadata applies to widget-accessible helper tools, not to the launcher solely because it opens the widget. The launcher input `business_id` must be the `provider_business_id` from the matching service provider record. It can differ from the ID of the containing local business record. ## Business feed requirements A business feed is a paginated collection of local business records that you provide to ChatGPT. ChatGPT indexes these records for search and uses their service provider data to determine whether a business supports **Get Quote**. ### Required business fields Each business record must include: - `id`: A stable business ID unique within your feed. - `name`: The business name. - `address`: A structured address or a human-readable formatted address. - `location`: An object containing `latitude` and `longitude`. - `phone_number`: A business phone number, preferably in E.164 format. - `website_url`: The business website. - `platform_url`: Your canonical listing URL for the business. ### Quote-request action For every business that accepts quote requests, add a `service_providers` array containing a record with these fields: - `provider`: Your provider name. It must match the configured partner plugin. - `provider_business_id`: Your nonempty identifier for the business. ChatGPT passes this value as `business_id` to `request_service`. - `action_type`: Set this to `request_a_quote` for a quote request. - `provider_action_url`: A valid absolute HTTP or HTTPS URL for your quote-request action. - `display_name`: An optional provider-supplied display name. ### Paginated listing endpoint Expose a listing endpoint such as `GET /v1/businesses` and support one pagination style: - `page` and `page_size`. - `offset` and `limit`. - An opaque `next_page_token`. Accept an optional `changes_token` to identify the previous synchronization checkpoint. Return `checksum` to show whether the feed has changed, `businesses` for the current page, and the metadata for your pagination style. For example, the following request fetches one business from a page-based feed: ```http GET /v1/businesses?page=1&page_size=1&changes_token=sync_001 ``` Return the complete business record and its quote-request action: ```json { "checksum": true, "page": 1, "page_size": 1, "total_pages": 1, "businesses": [ { "id": "local_biz_456", "name": "Acme Plumbing", "address": { "line1": "123 Market St", "locality": "San Francisco", "region": "CA", "postal_code": "94105", "country": "US", "formatted": "123 Market St, San Francisco, CA 94105, US" }, "location": { "latitude": 37.793, "longitude": -122.396 }, "phone_number": "+14155551234", "website_url": "https://acmeplumbing.example", "platform_url": "https://provider.example/businesses/local_biz_456", "service_providers": [ { "provider": "example_provider", "provider_business_id": "biz_123", "action_type": "request_a_quote", "provider_action_url": "https://provider.example/request-quote/biz_123", "display_name": "Get Quote" } ] } ] } ``` ### Quote-launch eligibility ChatGPT builds an in-chat launcher only when the containing business has a nonempty ID, the service provider has a nonempty `provider_business_id` and a valid `provider_action_url`, and the provider has a configured partner plugin. The quote button uses the ChatGPT UI label **Get Quote**; the provider's `display_name` does not override that label for `request_a_quote` actions. ## Future expansion This contract covers quote requests. Other service actions, such as appointment booking, are not required for the quote-request flow. --- # Optimize Metadata ## Why metadata matters ChatGPT and Codex decide when to call your tool based on the metadata you provide. Well-crafted names, descriptions, and parameter docs increase recall on relevant prompts and reduce accidental activations. Treat metadata like product copy—it needs iteration, testing, and analytics. ## Gather a golden prompt set Before you tune metadata, assemble a labelled dataset: - **Direct prompts:** users explicitly name your product or data source. - **Indirect prompts:** users describe the outcome they want without naming your tool. - **Negative prompts:** cases where built-in tools or other tools should handle the request. Document the expected behaviour for each prompt (call your tool, do nothing, or use an alternative). You will reuse this set during regression testing. ## Draft metadata that guides the model For each tool: - **Name:** pair the domain with the action (`calendar.create_event`). - **Description:** start with “Use this when…” and call out disallowed cases ("Do not use for reminders"). - **Parameter docs:** describe each argument, include examples, and use allowed values for constrained inputs. - **Read-only hint:** annotate `readOnlyHint: true` on tools that only retrieve or compute information and never create, update, delete, or send data outside the conversation. - For tools that are not read-only: - **Destructive hint** - annotate `destructiveHint: false` on tools that do not delete or overwrite user data. - **Open-world hint** - annotate `openWorldHint: false` on tools that do not publish content or reach outside the user's account. {/* vale Vale.Terms = NO */} ## Evaluate in developer mode {/* vale Vale.Terms = YES */} 1. In ChatGPT, turn on Developer mode from **Settings → Security and login**, then register your MCP server at [ChatGPT Plugins](https://chatgpt.com/plugins). 2. Run through the golden prompt set and record the outcome: which tool was selected, what arguments were passed, and whether the component rendered. 3. For each prompt, track precision (did the right tool run?) and recall (did the tool run when it should?). If the model picks the wrong tool, revise the descriptions to emphasise the intended scenario or narrow the tool’s scope. ## Iterate methodically - Change one metadata field at a time so you can attribute improvements. - Keep a log of revisions with timestamps and test results. - Share diffs with reviewers to catch ambiguous copy before you deploy it. After each revision, repeat the evaluation. Aim for high precision on negative prompts before chasing marginal recall improvements. ## Production monitoring Once your connector is live: - Review tool-call analytics weekly. Spikes in “wrong tool” confirmations usually indicate metadata drift. - Capture user feedback and update descriptions to cover common misconceptions. - Schedule periodic prompt replays, especially after adding new tools or changing structured fields. Treat metadata as a living asset. The more intentional you are with wording and evaluation, the easier discovery and invocation become. --- # Product checkout conversion spec Product checkout conversion plugins in ChatGPT are currently in beta and being tested with approved partners. To apply for access, fill out this form [here](https://chatgpt.com/merchants) ## Purpose Our goal is to let ChatGPT directly invoke partner plugins for high-intent use cases such as product checkout. Once partners provide us with a product feed for search, we can connect their MCP servers for bottom-of-funnel conversion actions. To do this, partner plugins must follow a standardized contract for widget name, tool name, and tool input. If you want to build a plugin that follows this spec, apply for access through the [ChatGPT merchants form](https://chatgpt.com/merchants/). ## User experience When users search for products, the product entity sidebar can show **Open** buttons for sellers. If a seller has a plugin, ChatGPT can open that plugin inline for checkout instead of punching out to an external website. ## Required contract (today) - Widget name: `ui://widget/checkout-session.html` - Tool name: `checkout_session` `checkout_session` must set: ```ts _meta.ui.resourceUri = "ui://widget/checkout-session.html"; ``` Any tool called directly from a widget must set: ```ts _meta["openai/widgetAccessible"] = true; ``` ## `checkout_session` input Each checkout item requires only `id` and `quantity`. `offerId` and metadata for the selected merchant offer are optional: ```json { "checkout_session": { "items": [ { "id": "string", "quantity": 1, "offerId": "string", "name": "Wireless headphones", "description": "Wireless headphones with noise cancellation.", "images": [ "https://merchant.example.com/images/headphones.jpg", "https://merchant.example.com/images/headphones-side.jpg" ], "url": "https://merchant.example.com/products/headphones", "merchant_name": "Example Merchant", "price": "$24.99" } ] } } ``` The following item fields are optional: | Field | Description | | --------------- | --------------------------------------------------- | | `offerId` | The identifier of the selected merchant offer. | | `name` | The selected offer's product name. | | `description` | A description from the merchant's own feed. | | `images` | Ordered merchant-feed image URLs; first is primary. | | `url` | The selected merchant offer's URL. | | `merchant_name` | The name of the selected merchant. | | `price` | The displayed offer price as a string. | `images` contains zero or more URLs from the selected merchant's feed. When present, the first URL is the primary displayed image. ChatGPT omits unavailable optional fields. It doesn't generate a description or use another merchant's description or images. Declare these item fields as optional in your tool's input schema, and use the checkout item and offer IDs to retrieve authoritative product, inventory, and pricing data from your own catalog. The nested checkout session aligns with the Commerce checkout session shape documented [here](https://developers.openai.com/commerce/specs/checkout/#post-checkout_sessions). --- # Restaurant reservation conversion spec Restaurant reservation conversion plugins in ChatGPT are currently in beta and being tested with approved partners. To apply for access, fill out this form [here](https://chatgpt.com/merchants) ## Purpose Our goal is to let ChatGPT directly invoke partner plugins for high-intent use cases such as restaurant reservations. Once partners provide us with a feed for search, we can connect their MCP servers for bottom-of-funnel conversion actions. To do this, partner plugins must follow a standardized contract for widget name, tool name, and tool input. If you want to build a plugin that follows this spec, apply for access through the [ChatGPT merchants form](https://chatgpt.com/merchants/). ## User experience When users search for restaurants around them, the restaurant entity card and sidebar include a **Reserve** button that can open the restaurant's reservation provider UI. Reserve button in restaurant UI: ![Reserve button in restaurant UI](https://developers.openai.com/images/apps-sdk/conversion-specs/reserve-button.png) Reservation modal opened from that button: ![Reservation modal opened from Reserve button](https://developers.openai.com/images/apps-sdk/conversion-specs/reservation-modal.png) ## Required contract (today) For the current reservation integration, only the following are required: - Widget name: `ui://widget/restaurant-reservation.html` - Tool name: `restaurant_reservation` `restaurant_reservation` must set: ```ts _meta.ui.resourceUri = "ui://widget/restaurant-reservation.html"; ``` Any tool called directly from a widget must set: ```ts _meta["openai/widgetAccessible"] = true; ``` ## `restaurant_reservation` input Minimum payload (always sent): ```json { "restaurant_id": "string" } ``` We might also send the payload below. You can use it for optimistic rendering in the modal (for example, to avoid skeleton/loading states while data hydrates): ```json { "restaurant_name": "string", "restaurant_image": "string", "restaurant_address": { "address": "string", "city": "string", "state": "string", "zipcode": "string", "country": "string" } } ``` ## Feed requirement (search integration) To enable Reserve-button routing, we ingest a business feed from partners. ### Purpose and scope This feed contract defines: - Minimum business data required for matching and ranking. - A paginated listing API. - Change detection so we can avoid unnecessary full fetches. ### Business record (minimum required fields) A `Business` object must include: - `id` (`string`): stable and unique within the provider. - `name` (`string`) - `address` (`object` or formatted `string`) - `location` (`object` with latitude/longitude) - `phone_number` (`string`, E.164 preferred) - `website_url` (`string`, URL) - `platform_url` (`string`, URL to your canonical listing) Recommended minimal shape: ```json { "id": "biz_123", "name": "Acme Coffee", "address": { "line1": "123 Market St", "line2": "Suite 5", "locality": "San Francisco", "region": "CA", "postal_code": "94105", "country": "US", "formatted": "123 Market St, Suite 5, San Francisco, CA 94105, US" }, "location": { "latitude": 37.793, "longitude": -122.396 }, "phone_number": "+14155551234", "website_url": "https://acmecoffee.example", "platform_url": "https://provider.example/biz/biz_123" } ``` If structured address components are unavailable, `address` may be a single formatted string, but it must be consistent and human-readable. ### Paginated listing endpoint Endpoint example: - `GET /v1/businesses` Query parameters: - Pagination: use one style - `page` + `page_size` - `offset` + `limit` - or `next_page_token` (opaque token; preferred when supported) - `changes_token` (`string`, optional): indicates whether data changed since the last sync checkpoint. Response must include: - `checksum` (`boolean`): whether anything changed since the provided `changes_token` (or `true` if none was provided). - `businesses` (`Business[]`): current page payload. - Pagination metadata for your selected style: - `page`, `page_size`, `total_pages` (optional), or - `offset`, `limit`, `total` (optional), or - `next_page_token` (`string | null`) ### Example request and response Request: ```http GET /v1/businesses?page=1&page_size=2&changes_token=sync_2026_03_10 ``` Response: ```json { "checksum": true, "page": 1, "page_size": 2, "total_pages": 120, "businesses": [ { "id": "biz_123", "name": "Acme Coffee", "address": { "line1": "123 Market St", "locality": "San Francisco", "region": "CA", "postal_code": "94105", "country": "US", "formatted": "123 Market St, San Francisco, CA 94105, US" }, "location": { "latitude": 37.793, "longitude": -122.396 }, "phone_number": "+14155551234", "website_url": "https://acmecoffee.example", "platform_url": "https://provider.example/biz/biz_123" }, { "id": "biz_124", "name": "Golden Diner", "address": "200 Howard St, San Francisco, CA 94105, US", "location": { "latitude": 37.789, "longitude": -122.391 }, "phone_number": "+14155559876", "website_url": "https://goldendiner.example", "platform_url": "https://provider.example/biz/biz_124" } ] } ``` ### How we use this feed for search We treat the business feed as a search index. At query time, we retrieve candidates with fuzzy matching (name + location/address), then rank and remove duplicates using name/address similarity, with location/phone/URL as additional signals. ## Good-to-have expansion (not required today) For full end-to-end in-chat completion, we recommend adding: - `refresh_availability` - `make_reservation` - `reservation_confirmation` --- # Security & Privacy ## Principles Plugin tools can access user data, third-party APIs, and write actions. Treat every MCP server and UI component as production software: - **Least privilege:** Only request the scopes, storage access, and network permissions you need. - **Explicit user consent:** Make sure users understand when they are linking accounts or granting write access. Use the host's confirmation prompts for destructive actions. - **Defense in depth:** Assume prompt injection and malicious inputs will reach your server. Check every input and keep audit logs. ## Data handling - **Structured content:** Include only the data required for the current prompt. Avoid embedding secrets or tokens in component props. - **Storage:** Decide how long you keep user data and publish a retention policy. Respect deletion requests. - **Logging:** Redact PII before writing to logs. Store correlation IDs for debugging but avoid storing raw prompt text unless necessary. ## Prompt injection and write actions Developer mode enables full MCP access, including write tools. Mitigate risk by: - Reviewing tool descriptions regularly to discourage misuse (“Do not use to delete records”). - Validating all inputs server-side even if the model provided them. - Requiring human confirmation for irreversible operations. Share your best prompts for testing injections with your QA team so they can probe weak spots early. ## Network access Widgets run inside an isolated iframe with a strict Content Security Policy. They cannot access privileged browser APIs such as `window.alert`, `window.prompt`, `window.confirm`, or `navigator.clipboard`. The CSP controls standard `fetch` requests. Nested frames are unavailable by default; enable specific origins in resource CSP metadata such as `_meta.ui.csp.frameDomains`. Work with your OpenAI partner if you need a specific domain added to the allowlist. Server-side code has no network restrictions beyond what your hosting environment enforces. Follow normal best practices for outbound calls (TLS verification, retries, timeouts). ## Authentication & authorization - Use OAuth 2.1 authorization-code flows when integrating external accounts. Prefer Client ID Metadata Documents (CIMD) when your authorization server supports CIMD and the plugin builder chooses it. Use `none` for public-client token exchange or `private_key_jwt` when your authorization server requires client authentication. Support DCR when the plugin builder chooses it or CIMD is not available. - Verify and enforce scopes on every tool call. Return a `401` response for expired or malformed tokens. - For built-in identity, avoid storing long-lived secrets; use the provided auth context instead. ## Operational readiness - Run security reviews before launch, especially if you handle regulated data. - Monitor for anomalous traffic patterns and set up alerts for repeated errors or failed auth attempts. - Keep third-party dependencies, libraries, and build tooling patched to mitigate supply chain risks. Security and privacy are foundational to user trust. Bake them into your planning, implementation, and deployment workflows rather than treating them as an afterthought. --- # Submit your Claude Code plugin to OpenAI If you already publish a Claude Code plugin or connector, choose the OpenAI submission path that matches what you ship. | What you have | OpenAI submission path | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | A skills-only Claude Code plugin | Follow [Submit a skills-only plugin](#submit-a-skills-only-plugin). | | A remote MCP connector | Follow [Submit a plugin with an MCP server](#submit-a-plugin-with-an-mcp-server). Skills are optional. | | A Claude Code plugin with skills and a remote MCP server | Follow [Submit a plugin with an MCP server](#submit-a-plugin-with-an-mcp-server) and include the skills in the same submission. | | A plugin with only local `stdio` MCP servers | We recommend exposing your MCP server as a public HTTP endpoint. If that isn't possible, wait until OpenAI supports local MCP servers. | | A Claude Desktop extension (`.mcpb`) | The portal doesn't accept `.mcpb` files. Expose its MCP server as a public HTTP endpoint, or wait until OpenAI supports local MCP servers. | Claude uses separate submission processes for Claude Code plugins and MCP connectors. OpenAI uses one plugin submission with either skills alone or skills and an optional remote MCP server. Claude marketplace listings and approvals don't transfer. ## Submit a skills-only plugin Choose this path when the plugin doesn't need an MCP server. ### Review what OpenAI supports | What your Claude plugin has | What to do | | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `skills` | Keep each skill with its `SKILL.md`, scripts, references, and assets. A direct Claude archive upload must include at least one skill at `skills//SKILL.md`. | | Manifest-declared custom skill directories | Keep the directories and their manifest declarations in the archive. | | Skills that explicitly refer to Claude | Replace Claude-specific references in the skill instructions with provider-neutral language, such as “the model.” Keep a product name only when the instruction genuinely applies to that product. | | `commands`, `commands/`, `agents`, or `agents/` | Convert reusable behavior to skills. Turn each Markdown command into a skill, move reusable agent procedures into skills, and merge useful persona instructions into the relevant skill. | | `hooks` or `hooks/hooks.json` | Adapt supported command hooks for Codex and test them against the [Codex hook runtime](https://developers.openai.com/codex/hooks). Don't require hooks for the core ChatGPT workflow. ChatGPT doesn't run plugin hooks yet, and Codex doesn't run prompt or agent hook handlers. | | `userConfig` or `${user_config.*}` | OpenAI doesn't run Claude installation prompts or expand `user_config` variables. Follow [Replace Claude `userConfig`](#replace-claude-userconfig). If the plugin needs credentials or persistent user settings, use the **With MCP** path. | | Skills that create or update Claude live artifacts | OpenAI doesn't currently support Claude live artifacts. Remove instructions that require creating, reopening, refreshing, or updating an artifact. Return the underlying content as regular conversation output instead; for example, render artifact tables as standard tables. Artifact-specific HTML, persistence, refresh behavior, and interactions aren't preserved. | | `bin/`, `settings`, `settings.json`, `CLAUDE.md`, or `.claude/settings*.json` | Keep required helpers and instructions in the plugin. Call bundled executables with package-relative paths, and remove Claude-only settings. | | `outputStyles`, `lspServers`, `experimental.themes`, `experimental.monitors`, `channels`, or `dependencies` | Move essential behavior into skills, then remove the Claude declaration. Contact your OpenAI partner if the core workflow requires inbound channel messages. | | `.claude-plugin/plugin.json` | Keep the manifest for a direct Claude archive upload. The portal converts it to `.codex-plugin/plugin.json`. | | `.claude-plugin/marketplace.json`, `.mcp.json`, `mcpServers`, `.app.json`, or `apps` | Don't rely on these files or declarations. A skills-only upload excludes MCP and app configuration, and you can't submit an existing app integration by reference. | ### Prepare and upload the archive 1. Confirm that the archive root, or its single top-level directory, includes `.claude-plugin/plugin.json` with a nonempty `description` and at least one valid skill at `skills//SKILL.md`. 2. Open the [plugin submission portal](https://platform.openai.com/plugins), select **Create plugin**, choose **Skills only**, and upload the archive. 3. Review the generated `.codex-plugin/plugin.json`. The portal adds missing interface defaults and normalizes text fields during conversion. 4. Test the imported skills in a clean environment. Confirm that each skill can find its referenced files and executables and doesn't depend on undeclared local packages, files, or credentials. 5. Complete the listing and review fields, fix every scan result, and submit the draft. If the archive doesn't qualify for direct upload, use [Package your plugin](https://developers.openai.com/plugins/build/plugins#plugin-structure) to create the OpenAI manifest and package layout. See [Build skills](https://developers.openai.com/plugins/build/skills) for skill requirements. ## Submit a plugin with an MCP server Choose this path for a plugin that needs both skills and an MCP server. ### Review what OpenAI supports | What your Claude integration has | What to do | | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A remote MCP server | Reuse the server implementation. Submit a stable, public HTTPS endpoint that uses streamable HTTP. | | A local `stdio` server, `.mcpb`, `.mcp.json`, or `claude_desktop_config.json` | We recommend exposing your MCP server as a public HTTP endpoint. If that isn't possible, wait until OpenAI supports local MCP servers. The portal doesn't accept `.mcpb` files. | | Skills or manifest-declared custom skill directories | Include the skills in the same **With MCP** submission. Keep each `SKILL.md` with its scripts, references, and assets. | | Skills that explicitly refer to Claude | Replace Claude-specific references in the skill instructions with provider-neutral language, such as “the model.” Keep a product name only when the instruction genuinely applies to that product. | | `commands`, `commands/`, `agents`, or `agents/` | Convert reusable behavior to skills. Turn each Markdown command into a skill, move reusable agent procedures into skills, and merge useful persona instructions into the relevant skill. | | `hooks` or `hooks/hooks.json` | Adapt supported command hooks for Codex and test them against the [Codex hook runtime](https://developers.openai.com/codex/hooks). Don't require hooks for the core ChatGPT workflow. ChatGPT doesn't run plugin hooks yet, and Codex doesn't run prompt or agent hook handlers. | | `userConfig` or `${user_config.*}` | OpenAI doesn't run Claude installation prompts or expand `user_config` variables. Follow [Replace Claude `userConfig`](#replace-claude-userconfig) to move each value to an explicit input, OAuth, hosted storage, or Codex-local configuration. | | Skills that create or update Claude live artifacts | OpenAI doesn't currently support Claude live artifacts. Remove instructions that require creating, reopening, refreshing, or updating an artifact. Return the underlying content as regular conversation output instead; for example, render artifact tables as standard tables. Artifact-specific HTML, persistence, refresh behavior, and interactions aren't preserved. | | `.app.json`, `apps`, or an existing app integration | Submit the MCP server endpoint directly. You can't submit an existing app integration by reference. | | `outputStyles`, `lspServers`, `experimental.themes`, `experimental.monitors`, `channels`, or `dependencies` | Move essential behavior into skills or MCP tools, then remove the Claude declaration. Contact your OpenAI partner if the core workflow requires inbound channel messages. | ### Prepare and submit the MCP server 1. Deploy the MCP server at its production HTTPS endpoint. Use OAuth 2.1 when the server accesses private user data or takes actions for a user. 2. Add accurate tool schemas and safety annotations. Test that every tool connects, authenticates, returns the expected result shape, and requires the intended confirmation for write or destructive actions. 3. Convert any commands or agents to skills and make sure the skills don't depend on undeclared local packages, files, or credentials. 4. Open the [plugin submission portal](https://platform.openai.com/plugins), select **Create plugin**, choose **With MCP**, and submit the production endpoint. Add the converted skills to the same draft when applicable. 5. Verify the server domain, configure authentication when the server requires sign-in, complete the listing and review fields, fix every scan result, and submit the draft. Review the [MCP server review requirements](https://developers.openai.com/plugins/deploy/app-review) before submitting. ## Replace Claude `userConfig` OpenAI plugins don't run Claude `userConfig` installation prompts or expand `${user_config.*}` references. Remove those references and replace each value based on how the plugin uses it. | What the value controls | OpenAI replacement | | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A choice that can change for each task | Add an explicit skill or MCP tool input. Ask for the value only when the workflow needs it. | | A credential for a remote service | Use OAuth 2.1 through the remote MCP server. Don't put secrets in the skill archive, manifest, instructions, or default values. | | A preference that should persist | Store it in the hosted service and associate it with the authenticated user. Let the user update it through an explicit workflow or tool input. | | A setting for a Codex-local script or hook | Use a documented environment variable or config file. Check it before use and return an actionable error when missing. Don't make this local setting necessary for the core ChatGPT workflow. | | A fixed value that is the same for all users | Put a non-secret default in the skill instructions or hosted service configuration. | If a skills-only plugin needs a credential or a setting that must persist between conversations, add a remote MCP server and submit it through **With MCP**. If the value only affects the current task, keep the plugin skills-only and collect it as an explicit skill input. ## Complete the submission requirements Before submitting either plugin, get **Apps Management** write access in the OpenAI organization that will own it. You must also complete individual or business identity verification. Every plugin must complete OpenAI review. Contact your OpenAI partner before submitting if the plugin's core value requires local execution, arbitrary access to files on the user's computer, hardware or application access, offline operation, or inbound channel messages. These cases may need a different design or product-specific review. For the complete portal workflow, see [Submit plugins](https://developers.openai.com/plugins/deploy/submission). If the portal reports a package validation code, use the [submission error reference](https://developers.openai.com/plugins/deploy/submission-errors). --- # Brainstorm plugin use cases Start by listing the things people will expect your plugin to do. The plugin's name, description, skills, tools, and connection to an existing product all create expectations. Your implementation should cover those expectations or have a deliberate reason not to. This work determines what belongs in the plugin: - Add a **skill** when instructions, examples, or bundled resources can guide the model through the workflow. - Add an **MCP server** when the workflow needs live data, authentication, controlled tools, or code that runs on infrastructure you operate. - Add **UI to the MCP server** only when visual interaction materially improves part of the workflow. ## Start from user expectations Imagine that a person has installed your plugin but has not read its documentation. What would they reasonably ask it to do? Gather likely requests from: - Tasks people already complete in your product or service. - User interviews, support requests, search queries, and feature requests. - Common terms people use for your product, data, and workflows. - Existing workarounds that require copying data between tools. - The plugin name, listing, screenshots, and starter prompts. Include direct requests that name your plugin and indirect requests that state the goal. For example, a project-management plugin might need to handle both “Show my Acme launch board” and “What is blocking the launch?” Do not limit the brainstorm to workflows that fit your current API. First capture what people will expect. Then compare those expectations with what you can support safely and reliably. ## Build a use-case inventory For each use case, record: | Field | Question to answer | | ----------------- | -------------------------------------------------------------------------- | | User goal | What is the person trying to accomplish? | | Example requests | How might they ask directly or indirectly? | | Expected result | What would make the interaction successful? | | Required context | What information, account access, or prior state is needed? | | Plugin capability | Can a skill handle it, or does it need an MCP tool? | | Safety boundary | Could it expose data, change state, spend money, or affect another person? | | Support decision | Will the first version support it, defer it, or intentionally exclude it? | Group requests that share the same goal. “List my open tasks,” “What do I need to do today?” and “Show overdue work” may belong to one task-review use case with different filters rather than three unrelated features. ## Check coverage Review every expectation against the proposed plugin capabilities: 1. Confirm that each supported use case has a complete path from request to useful result. 2. Identify missing skills, tools, data, permissions, or error states. 3. Look for tools that expose technical operations without completing a recognizable user goal. 4. Verify that write actions include appropriate authorization and confirmation. 5. Check that the plugin can explain what it cannot do and offer a useful next step. A plugin should not imply broad capability while supporting only a narrow slice of the expected workflow. If users can create projects but cannot list, inspect, or update them, either add the missing coverage or narrow the plugin's positioning. ## Document intentional exclusions You do not need to implement every imaginable request. You should have a good reason for each important exclusion, such as: - The action would create unacceptable safety or privacy risk. - The underlying product or API does not support it reliably. - The workflow requires permissions that the plugin cannot verify. - The result would be misleading without information the plugin cannot access. - The use case is out of scope for the first release and the plugin's listing sets that expectation. Record these decisions. They should inform skill boundaries, tool descriptions, refusal behavior, test cases, and public listing copy. ## Turn use cases into build decisions For each supported use case, choose the smallest implementation that can complete it: - [Build a skill](https://developers.openai.com/plugins/build/skills) for repeatable instructions and resources. - [Build an MCP server](https://developers.openai.com/plugins/build/mcp-server) for live data and controlled actions. - [Add UI to the MCP server](https://developers.openai.com/plugins/build/chatgpt-ui) when people need to inspect, compare, edit, confirm, or navigate structured information. Keep the use-case inventory as a test plan. Add representative direct, indirect, edge-case, and out-of-scope requests, then verify that the finished plugin behaves as intended for each one. If the plugin needs live data or controlled actions, continue with [Define tools](https://developers.openai.com/plugins/plan/tools). --- # Define tools Tools are the actions and data that a plugin's MCP server exposes to ChatGPT and Codex. Define them after you [brainstorm use cases](https://developers.openai.com/plugins/plan/use-case) and before you implement the server. Every tool should help complete a user goal. Do not mirror an internal API without considering how people will ask for and use the capability. ## Map use cases to tools For each supported use case: 1. Write the outcome the user expects. 2. List the information required to produce that outcome. 3. Identify the reads, writes, or external actions the server must perform. 4. Group operations that represent one coherent action. 5. Split operations when they have different permissions, safety risks, or confirmation requirements. For example, a project plugin might expose: - `list_projects` to find projects. - `get_project` to inspect one project. - `create_project` to create a project. - `update_project` to change project details. - `archive_project` to perform a consequential state change. Separate read and write behavior so the model and user can distinguish information retrieval from actions that change state. ## Define each contract Record the following for every proposed tool: | Field | What to define | | ---------------- | -------------------------------------------------------------------- | | Name | A stable, action-oriented identifier. | | Title | A concise human-readable action. | | Description | The user goal and conditions that should trigger the tool. | | Input schema | Required and optional parameters, types, allowed values, and limits. | | Output schema | Structured fields the model can inspect and reuse. | | Authorization | The account, role, or resource access the server must verify. | | Side effects | Data or external state the tool can change. | | Failure behavior | Errors the model can explain or recover from. | Use explicit inputs. Do not depend on the model guessing identifiers, account scope, or other values that are required for correctness. Return stable identifiers and enough structured information for follow-up calls. Keep secrets, access tokens, internal diagnostics, and unnecessary personal data out of results. ## Write descriptions for selection The model uses tool descriptions to decide when a tool fits a request. Describe the user intent, not the implementation. Good descriptions: - State what the tool does. - Explain when to use it. - Distinguish it from similar tools. - Call out important limits or prerequisites. Avoid descriptions that only restate the tool name or expose internal service terminology that users do not know. ## Plan safety annotations Assign annotations based on actual behavior. See the MCP [`ToolAnnotations` schema](https://modelcontextprotocol.io/specification/2025-11-25/schema#toolannotations) for the canonical definitions, defaults, and interactions between these hints: - `readOnlyHint` is `true` only when the tool cannot change state. - `destructiveHint` is `true` when the tool can cause irreversible or difficult to reverse outcomes. - `openWorldHint` is `true` when the tool can affect public or external systems. Annotations do not replace server-side authorization, input validation, or confirmation for consequential actions. ## Check coverage and boundaries Compare the proposed tools with the complete use-case inventory: 1. Confirm that every supported use case has a path to a useful result. 2. Identify tools that do not serve a documented use case. 3. Look for missing reads that users need before taking a write action. 4. Verify that unsupported requests produce an understandable limitation instead of an unsafe approximation. 5. Test whether two similar tools have overlapping descriptions that could confuse selection. Keep the resulting tool plan as an implementation and evaluation checklist. Then [build the MCP server](https://developers.openai.com/plugins/build/mcp-server) and test each contract with representative, invalid, and unauthorized inputs. --- # Quickstart Plugins extend and customize ChatGPT and Codex. They can add capabilities, connect to external services, or both. A plugin can include skills that provide instructions and resources, an MCP server that exposes tools, or both. ChatGPT and Codex share one universal plugin directory. Public plugins are published once and become discoverable from supported surfaces in both products. This tutorial creates a personal plugin by connecting an MCP server. By the end, you will find the plugin in your personal Plugins directory and invoke its tool from ChatGPT Work on the web. Custom UI is optional and is not part of this quickstart. This quickstart uses a public example MCP server at `https://tinymcp.dev/api/moldy-aloof-zettabyte/mcp`. It exposes a read-only `roll_dice` tool and does not require authentication. ## Connect your MCP server First, add your deployed MCP server in ChatGPT developer mode: 1. Open [ChatGPT](https://chatgpt.com). 2. Open **Settings → Security and login** and turn on **Developer mode**. 3. Go to [ChatGPT Plugins](https://chatgpt.com/plugins), select the plus button, and enter `https://tinymcp.dev/api/moldy-aloof-zettabyte/mcp` as the MCP server URL. 4. Complete the connection details and create the plugin. ## Test the plugin 1. Go to [your personal plugins](https://chatgpt.com/plugins?view=personal). The plugin you created from the MCP server should appear there. 2. Open the plugin and select the plus button to install it. 3. Return to the [ChatGPT homepage](https://chatgpt.com). 4. At the top of the homepage, switch the tab from **Chat** to **Work**. 5. Start a new Work chat. In the prompt box, type `@` and select your plugin to invoke it directly. 6. Ask the plugin to roll one 20-sided die. Confirm that it calls `roll_dice` once with `sides` set to 20 and returns one value from 1 through 20. Test several realistic inputs, including different die sizes, invalid values, and requests that should not call the tool. Refine the tool metadata when the wrong tool is selected or its arguments are inconsistent. ## Add more capabilities Add more focused tools when the use-case inventory calls for them. To package reusable instructions with the MCP server, continue with [Build skills](https://developers.openai.com/plugins/build/skills) and [Package your plugin](https://developers.openai.com/plugins/build/plugins). If a workflow benefits from visual interaction, continue with [Add UI to your MCP server](https://developers.openai.com/plugins/build/chatgpt-ui). UI remains optional. ## Publish the plugin When the plugin is ready for other people, review the complete [plugin build guide](https://developers.openai.com/plugins/build/plugins). To publish it publicly, use the [plugin submission portal](https://developers.openai.com/plugins/deploy/submission). --- # Reference **Start with the open standard.** Use the [MCP Apps specification](https://modelcontextprotocol.io/docs/extensions/apps) for shared UI fields and bridge methods. **OpenAI extensions are optional** and live in `window.openai` when you want ChatGPT-specific capabilities. ## `window.openai` component bridge ChatGPT provides `window.openai` for compatibility aliases and optional ChatGPT extensions. New UI should use the MCP Apps bridge whenever the shared specification provides an equivalent, then use `window.openai` only for ChatGPT-specific capabilities. See [build a ChatGPT UI](https://developers.openai.com/plugins/build/chatgpt-ui) for implementation walkthroughs. If your tool requires confirmation, treat missing initial `toolInput` as expected. ChatGPT does not load approval-gated arguments into widget values before approval; instead, the host delivers them through `ui/notifications/tool-input` once the user approves the call. ### Capabilities | Capability | What it does | Typical use | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | State & data | `window.openai.toolInput` | Arguments supplied when the tool was invoked. For approval-gated tools, this may remain `null` until the host sends `ui/notifications/tool-input` after approval. | | State & data | `window.openai.toolOutput` | Your `structuredContent`. Keep fields concise; the model reads them verbatim. | | State & data | `window.openai.toolResponseMetadata` | Canonical widget-only tool result metadata. In ChatGPT this includes `status`, `call_tool_result`, and `mcp_tool_result`, preserving the full MCP result envelope, including hidden `_meta`. | | State & data | `window.openai.widgetState` | Snapshot of UI state persisted between renders. | | State & data | `window.openai.setWidgetState(state)` | Stores a new snapshot synchronously; call it after every meaningful UI interaction. | | Widget runtime APIs | `window.openai.callTool(name, args)` | Invoke another MCP tool from the widget (mirrors model-initiated calls). | | Widget runtime APIs | `window.openai.sendFollowUpMessage({ prompt, scrollToBottom })` | Ask ChatGPT to post a message authored by the component. `scrollToBottom` is optional, defaults to `true`, and can be set to `false` to prevent automatic scrolling. | | Widget runtime APIs | `window.openai.uploadFile(file, { library?: boolean })` | Upload a user-selected file and receive a `fileId`. Pass `{ library: true }` to also save the upload in the user's ChatGPT file library when that library is available. | | Widget runtime APIs | `window.openai.selectFiles()` | Open ChatGPT's file library picker and return plugin-authorized files as `{ fileId, fileName, mimeType }[]`. Feature-detect this helper because the file library may not be available to all users. | | Widget runtime APIs | `window.openai.getFileDownloadUrl({ fileId })` | Retrieve a temporary download URL for a file uploaded by the widget, selected from the file library, passed via file params, or returned by tool file references. | | Widget runtime APIs | `window.openai.requestDisplayMode(...)` | Request PiP/fullscreen modes. | | Widget runtime APIs | `window.openai.requestModal({ params, template })` | Spawn a modal owned by ChatGPT. Omit `template` to use the current template, or pass a registered template URI to switch modal content. | | Widget runtime APIs | `window.openai.requestClose()` | Ask ChatGPT to close the current widget. | | Widget runtime APIs | `window.openai.notifyIntrinsicHeight(...)` | Report dynamic widget heights to avoid scroll clipping. | | Widget runtime APIs | `window.openai.openExternal({ href, redirectUrl })` | Open a vetted external link in the user's browser. For approved redirect targets, ChatGPT appends `?redirectUrl=...` by default; set `redirectUrl: false` to skip it. | | Widget runtime APIs | `window.openai.setOpenInAppUrl({ href })` | Optionally override the external target shown in fullscreen. If unset, ChatGPT keeps the default behavior and opens the component's current iframe path. | | Context | `window.openai.theme`, `window.openai.displayMode`, `window.openai.maxHeight`, `window.openai.safeArea`, `window.openai.view`, `window.openai.userAgent`, `window.openai.locale` | Environment signals you can read or subscribe to through `useOpenAiGlobal` to adapt visuals and copy. | ### `useOpenAiGlobal` helper Many ChatGPT UI projects wrap `window.openai` access in small helper functions so views remain testable. This example helper listens for host `openai:set_globals` events and lets React components subscribe to a single global value: ```ts export function useOpenAiGlobal( key: K ): WebplusGlobals[K] { return useSyncExternalStore( (onChange) => { const handleSetGlobal = (event: SetGlobalsEvent) => { const value = event.detail.globals[key]; if (value === undefined) { return; } onChange(); }; window.addEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal, { passive: true, }); return () => { window.removeEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal); }; }, () => window.openai[key] ); } ``` ### Close the UI Call `window.openai.requestClose()` to ask ChatGPT to close the current UI. ### Request another presentation mode Use `window.openai.requestDisplayMode` to request inline, picture-in-picture, or fullscreen presentation: ```tsx await window.openai?.requestDisplayMode({ mode: "fullscreen" }); // On mobile, picture-in-picture may be presented as fullscreen. ``` ### Open a modal Use `window.openai.requestModal` to open a host-controlled modal. Provide the URI of another UI template registered by the same MCP server, or omit `template` to open the current template: ```tsx await window.openai.requestModal({ template: "ui://widget/checkout.html", }); ``` ## File APIs ChatGPT supports file upload/download helpers as optional `window.openai` extensions. | API | Purpose | Notes | | ------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `window.openai.uploadFile(file, { library?: boolean })` | Upload a user-selected file and receive a `fileId`. | Pass `{ library: true }` to also save the upload in the user's ChatGPT file library when that library is available to the current user. | | `window.openai.selectFiles()` | Open the file library picker for existing files. | Returns `[{ fileId, fileName, mimeType }]`. Feature-detect this helper because the file library may not be available to all users. | | `window.openai.getFileDownloadUrl({ fileId })` | Request a temporary download URL for a file. | Works for files uploaded by the widget, selected from the file library, passed via file params, or returned by tool file references. | The ChatGPT file library is optional and may not be available to every user. Files returned from `window.openai.selectFiles()` are already authorized for the current plugin when the helper is available. Use the returned `fileId` with `window.openai.getFileDownloadUrl({ fileId })` or in a tool input that uses file params. Upload a user-selected file: ```tsx const { fileId } = await window.openai.uploadFile(file, { library: true, }); ``` Select files that the user already uploaded to ChatGPT: ```tsx if (window.openai?.selectFiles) { const files = await window.openai.selectFiles(); // [{ fileId, fileName, mimeType }] } ``` Feature-detect `window.openai.selectFiles` and fall back to `window.openai.uploadFile` when the file library is unavailable. Request a temporary download URL: ```tsx const { downloadUrl } = await window.openai.getFileDownloadUrl({ fileId }); ``` ### Define file inputs To let ChatGPT pass files to a tool, list each top-level file input in `_meta["openai/fileParams"]`. Each listed field must resolve to a file object or an array of file objects. Every file object schema must declare all four supported properties: | Property | Type | Declare in `properties` | Include in `required` | | -------------- | -------- | :---------------------: | :-------------------: | | `download_url` | `string` | Yes | Yes | | `file_id` | `string` | Yes | Yes | | `mime_type` | `string` | Yes | No | | `file_name` | `string` | Yes | No | `mime_type` and `file_name` are optional values, but you must declare their properties in the schema. The **Scan Tools** step and plugin submission reject a file schema that omits any of the four properties, does not require `download_url` and `file_id`, marks either optional property as required, or requires a property other than `download_url` or `file_id`. You can declare extra optional properties. This complete tool descriptor accepts one required file input: ```json { "name": "analyze_file", "title": "Analyze file", "description": "Analyzes a user-provided file without modifying it.", "inputSchema": { "type": "object", "$defs": { "OpenAIFile": { "type": "object", "properties": { "download_url": { "type": "string" }, "file_id": { "type": "string" }, "mime_type": { "type": "string" }, "file_name": { "type": "string" } }, "required": ["download_url", "file_id"], "additionalProperties": false } }, "properties": { "file": { "$ref": "#/$defs/OpenAIFile" } }, "required": ["file"] }, "annotations": { "readOnlyHint": true, "openWorldHint": false, "destructiveHint": false }, "_meta": { "openai/fileParams": ["file"] } } ``` To accept more than one file, define the top-level field as an array and use the same file object schema in `items`. The tool can require the top-level file field independently of the properties required inside each file object. At runtime, ChatGPT passes file values with snake case fields: ```json { "download_url": "https://...", "file_id": "file_...", "mime_type": "image/png", "file_name": "input.png" } ``` ChatGPT always includes `download_url` and `file_id`; it may omit `mime_type` and `file_name`. Use `file_id` as the `fileId` value for `window.openai.getFileDownloadUrl({ fileId })` when a widget needs a fresh temporary download URL. When persisting widget state, use the structured shape (`modelContent`, `privateContent`, `imageIds`) if you want the model to see image IDs during follow-up turns. ## Host-backed navigation The sandbox runtime mirrors navigation history from the iframe into ChatGPT's UI. Use standard routing APIs, such as React Router, and the host keeps its navigation controls in sync with your UI. Router setup with React Router's `BrowserRouter`: ```tsx export default function PizzaListRouter() { return ( }> } /> ); } ``` Programmatic navigation: ```ts const navigate = useNavigate(); function openDetails(placeId: string) { navigate(`place/${placeId}`, { replace: false }); } function closeDetails() { navigate("..", { replace: true }); } ``` ## Tool descriptor parameters By default, a tool description should include the fields listed [here](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool). Declare `outputSchema` for any tool that returns `structuredContent`. The schema should describe the exact object your tool returns so clients can validate results and the model can reason about follow-up tool calls. ### `_meta` fields on tool descriptor Use these `_meta` fields on the tool descriptor. Prefer the MCP Apps standard key `_meta.ui.resourceUri` for linking a tool to a UI template. ChatGPT supports OpenAI-specific metadata for compatibility and optional extensions. | Key | Placement | Type | Limits | Purpose | | ----------------------------------------- | :-------------: | ------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `_meta["securitySchemes"]` | Tool descriptor | array | None | Back-compat mirror for clients that only read `_meta`. | | `_meta.ui.resourceUri` | Tool descriptor | string (URI) | None | Standard resource URI for the UI template. | | `_meta.ui.visibility` | Tool descriptor | string[] | default `["model", "app"]` | Controls whether a tool is available to the model, the UI, or both. The `app` value is the MCP Apps protocol identifier for UI. | | `_meta["openai/outputTemplate"]` | Tool descriptor | string (URI) | None | OpenAI-specific optional/compatibility alias for `_meta.ui.resourceUri` in ChatGPT. | | `_meta["openai/widgetAccessible"]` | Tool descriptor | boolean | default `false` | OpenAI-specific compatibility field used by existing UI integrations; prefer `_meta.ui.visibility` + `tools/call`. | | `_meta["openai/visibility"]` | Tool descriptor | string | `public` (default) or `private` | OpenAI-specific compatibility field used by existing UI integrations; prefer `_meta.ui.visibility`. | | `_meta["openai/toolInvocation/invoking"]` | Tool descriptor | string | ≤ 64 chars | Short status text while the tool runs. | | `_meta["openai/toolInvocation/invoked"]` | Tool descriptor | string | ≤ 64 chars | Short status text after the tool completes. | | `_meta["openai/fileParams"]` | Tool descriptor | string[] | None | List of top-level input fields that represent files. Each field receives `{ download_url, file_id, mime_type?, file_name? }`. | Example: ```ts registerAppTool( server, "search", { title: "Public Search", description: "Search public documents.", inputSchema: { q: z.string() }, outputSchema: { results: z.array( z.object({ id: z.string(), title: z.string(), url: z.string(), }) ), }, securitySchemes: [ { type: "noauth" }, { type: "oauth2", scopes: ["search.read"] }, ], _meta: { securitySchemes: [ { type: "noauth" }, { type: "oauth2", scopes: ["search.read"] }, ], ui: { resourceUri: "ui://widget/story.html" }, // Optional compatibility alias (ChatGPT only): // "openai/outputTemplate": "ui://widget/story.html", "openai/toolInvocation/invoking": "Searching…", "openai/toolInvocation/invoked": "Results ready", }, }, async ({ q }) => { const results = await performSearch(q); return { structuredContent: { results }, content: [{ type: "text", text: `Found ${results.length} results.` }], }; } ); ``` ### Annotations To label a tool as "read-only," use the following [`ToolAnnotations` fields](https://modelcontextprotocol.io/specification/2025-11-25/schema#toolannotations) on the tool descriptor: | Key | Type | Required | Notes | | ----------------- | ------- | :------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `readOnlyHint` | boolean | Required | Signal that the tool only retrieves or computes information and doesn't create, update, delete, or send data outside the conversation. | | `destructiveHint` | boolean | Required | Declare that the tool may delete or overwrite user data so the host knows to elicit explicit approval first. | | `openWorldHint` | boolean | Required | Declare that the tool publishes content or reaches outside the current user’s account, prompting the client to summarize the impact before asking for approval. | | `idempotentHint` | boolean | Optional | Declare that calling the tool with the same arguments has no extra effect on its environment. | These hints only influence how ChatGPT or Codex frames the tool call to the user; servers must still enforce their own authorization logic. Example: ```ts server.registerTool( "list_saved_recipes", { title: "List saved recipes", description: "Returns the user’s saved recipes without modifying them.", inputSchema: {}, outputSchema: { recipes: z.array( z.object({ id: z.string(), title: z.string(), }) ), }, annotations: { readOnlyHint: true }, }, async () => ({ structuredContent: { recipes: await fetchSavedRecipes() }, }) ); ``` ## Component resource `_meta` fields Set these keys on the resource template that serves your component (`registerResource`). They help ChatGPT describe and frame the rendered iframe without leaking metadata to other clients. | Key | Placement | Type | Purpose | | ------------------------------------- | :---------------: | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `_meta.ui.prefersBorder` | Resource contents | boolean | Hint that the component should render inside a bordered card when supported. | | `_meta.ui.csp` | Resource contents | object | Preferred metadata surface for standard widget CSP fields: `connectDomains`, `resourceDomains`, and optional `frameDomains`. | | `_meta.ui.domain` | Resource contents | string (origin) | Dedicated origin for hosted components (required when submitting a plugin with UI; must be unique per plugin). Defaults to `https://web-sandbox.oaiusercontent.com`. | | `_meta["openai/widgetDescription"]` | Resource contents | string | Human-readable summary surfaced to the model when the component loads, reducing redundant assistant narration. | | `_meta["openai/widgetPrefersBorder"]` | Resource contents | boolean | OpenAI-specific compatibility alias for `_meta.ui.prefersBorder` in ChatGPT. | | `_meta["openai/widgetCSP"]` | Resource contents | object | Legacy ChatGPT compatibility key for widget CSP metadata. Standard CSP fields are superseded by `_meta.ui.csp`, but `redirect_domains` is still required for trusted `openExternal` destinations. | | `_meta["openai/widgetDomain"]` | Resource contents | string (origin) | OpenAI-specific compatibility alias for `_meta.ui.domain` in ChatGPT. | ChatGPT supports the legacy `_meta["openai/widgetCSP"]` compatibility key with the following snake_case field names: - `connect_domains`: `string[]` - `resource_domains`: `string[]` - `frame_domains?`: `string[]` - `redirect_domains?`: `string[]`. ChatGPT extension for `window.openai.openExternal` redirect targets. The standard `_meta.ui.csp` object is generally preferred for new UI and supports: - `connectDomains`: `string[]`. Domains the widget may contact via fetch/XHR. - `resourceDomains`: `string[]`. Domains for static assets (images, fonts, scripts, styles). - `frameDomains?`: `string[]`. Optional list of origins allowed for iframe embeds. By default, widgets can't render subframes; adding `frameDomains` opts in to iframe usage and triggers stricter plugin review. However, `_meta.ui.csp` does not support `redirect_domains` for `window.openai.openExternal(...)` links. To allowlist redirect targets, you must still set `_meta["openai/widgetCSP"].redirect_domains`. ## Tool results Tool results can contain the following [fields](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-result). Notably: | Key | Type | Required | Notes | | ------------------- | --------------------- | -------- | ----------------------------------------------------------------------------------------------- | | `structuredContent` | object | Optional | Surfaced to the model and the component. Must match the declared `outputSchema`, when provided. | | `content` | string or `Content[]` | Optional | Surfaced to the model and the component. | | `_meta` | object | Optional | Delivered only to the component. Hidden from the model. | Only `structuredContent` and `content` appear in the conversation transcript. The host forwards `_meta` to the component so you can hydrate UI without exposing the data to the model. Host-provided tool result metadata: | Key | Placement | Type | Purpose | | --------------------------------- | :-----------------------------: | ------ | ----------------------------------------------------------------------------------------------------------------------- | | `_meta["openai/widgetSessionId"]` | Tool result `_meta` (from host) | string | Stable ID for the currently mounted widget instance; use it to correlate logs and tool calls until the widget unmounts. | Example: ```ts registerAppTool( server, "get_zoo_animals", { title: "get_zoo_animals", inputSchema: { count: z.number().int().min(1).max(20).optional() }, outputSchema: { animals: z.array( z.object({ id: z.string(), name: z.string(), species: z.string(), }) ), }, _meta: { ui: { resourceUri: "ui://widget/widget.html" } }, }, async ({ count = 10 }) => { const animals = generateZooAnimals(count); return { structuredContent: { animals }, content: [{ type: "text", text: `Here are ${animals.length} animals.` }], _meta: { allAnimalsById: Object.fromEntries( animals.map((animal) => [animal.id, animal]) ), }, }; } ); ``` ### Error tool result To return an error on the tool result, use the following `_meta` key: | Key | Purpose | Type | Notes | | ------------------------------- | ------------ | ------------------ | -------------------------------------------------------- | | `_meta["mcp/www_authenticate"]` | Error result | string or string[] | RFC 7235 `WWW-Authenticate` challenges to trigger OAuth. | ## `_meta` fields the client provides | Key | When provided | Type | Purpose | | ------------------------------ | ----------------------- | --------------- | -------------------------------------------------------------------------------------------- | | `_meta["openai/locale"]` | Initialize + tool calls | string (BCP 47) | Requested locale (older clients may send `_meta["webplus/i18n"]`). | | `_meta["openai/userAgent"]` | Tool calls | string | Optional, best-effort user agent hint for analytics or formatting. | | `_meta["openai/userLocation"]` | Tool calls | object | Coarse location hint (`city`, `region`, `country`, `timezone`, `longitude`, `latitude`). | | `_meta["openai/subject"]` | Tool calls | string | Anonymized user id sent to MCP servers for the purposes of rate limiting and identification | | `_meta["openai/session"]` | Tool calls | string | Anonymized conversation id for correlating tool calls within the same ChatGPT session. | | `_meta["openai/organization"]` | Tool calls | string | Anonymized organization id associated with the current ChatGPT organization, when available. | Operation-phase `_meta["openai/userAgent"]` and `_meta["openai/userLocation"]` are hints only; servers should never rely on them for authorization decisions and must tolerate their absence. Treat `_meta["openai/userAgent"]` as optional, best-effort metadata rather than a stable way to detect which host surface is calling your server. Example: ```ts server.registerTool( "recommend_cafe", { title: "Recommend a cafe", inputSchema: {}, outputSchema: { cafes: z.array( z.object({ name: z.string(), address: z.string(), }) ), }, }, async (_args, { _meta }) => { const locale = _meta?.["openai/locale"] ?? "en"; const location = _meta?.["openai/userLocation"]?.city; const cafes = await findNearbyCafes(location); return { content: [{ type: "text", text: formatIntro(locale, location) }], structuredContent: { cafes }, }; } ); ```