Hace unas semanas, OpenAI introdujo Apps para ChatGPT. Como se puede ver a continuación, permite a las empresas inyectar su producto directamente en el chat para ayudar a satisfacer la solicitud del usuario. Una aplicación puede ser desencadenada por una mención explícita o cuando el modelo decide que la aplicación va a ser útil. So, what is a ChatGPT App? Para un cliente, es una forma de obtener una experiencia de usuario más rica y funcionalidad, más allá de las restricciones de una interfaz de texto. Para una empresa, es una forma de llegar a más de 800 millones de usuarios de ChatGPT en el momento adecuado. Para un desarrollador, es un servidor MCP y una aplicación web que se ejecuta en un iframe <= eso es lo que estamos aquí para hablar! Demo En este post, voy a caminar a través de la construcción de una aplicación de cuestionario simple, que se muestra a continuación, usando como ejemplo para demostrar las características disponibles. Nota importante: Si desea seguir adelante, necesitará una suscripción de ChatGPT de pago para habilitar el Modo de Desarrollador. Si quieres seguirme, Una suscripción de cliente estándar de $ 20 / mes será suficiente. Important Note: you will need a paid ChatGPT subscription to enable Developer Mode Flujo de alto nivel Aquí está cómo funciona a un nivel alto (el orden real de pasos puede variar ligeramente): En primer lugar, el desarrollador de la aplicación la registra dentro de ChatGPT por Esto permite que la app . el Están por , y permite que modelos como ChatGPT exploren e interactúen con otros servicios. “Y” necesitaba crear una app de quiz ChatGPT. En este paso, providing a link to the MCP server (1) MCP Model Context Protocol tools resources ChatGPT learns and remembers what our app does and when it can be useful. Cuando la aplicación ya existe y el usuario hace una llamada como “Haz un cuestionario sobre Sam Altman”, ChatGPT comprobará si hay una App que puede usar en lugar de una respuesta de texto para proporcionar una mejor experiencia al usuario . (2) (3) Si se encuentra una App, ChatGPT mira el esquema de los datos que la App necesita Nuestra Aplicación necesita recibir los datos en el siguiente formato JSON: (4) { questions: [ { question: "Where was Sam Altman born", options: ["San Francisco", ...], correctIndex: 2, ... }, ... ] } Esto se llama , y lo enviará a nuestra app . ChatGPT will generate quiz data exactly in this format toolInput (5) La aplicación procesará el y producirán ChatGPT renderizará HTML “recurso” proporcionado por la aplicación en la ventana de chat, y la inicializará con data Y finalmente, el usuario verá la aplicación y podrá interactuar con ella. . toolInput toolOutput toolOutput (6) (7) Creación de un servidor MCP Código repo para nuestra aplicación ChatGPT: . https://github.com/renal128/quizaurus-tutorial Hay 2 proyectos: y En primer lugar, nos centraremos en que utiliza JavaScript simple en el frontend para mantener las cosas simples. quizaurus-plain quizaurus-react quizaurus-plain ¡Todo el código del servidor está en este archivo - sólo unas 140 líneas de código! https://github.com/renal128/quizaurus-tutorial/blob/main/quizaurus-plain/src/server.ts Configuración del servidor Hay muchas opciones para crear un servidor MCP usando cualquiera de los SDK listados aquí: https://modelcontextprotocol.io/docs/sdk En este caso, utilizaremos el . Tipografía MCP SDK El código de abajo muestra cómo configurarlo: // Create an MCP server const mcpServer = new McpServer({ name: 'quizaurus-server', version: '0.0.1' }); // Add the tool that receives and validates questions, and starts a quiz mcpServer.registerTool( ... ); // Add a resource that contains the frontend code for rendering the widget mcpServer.registerResource( ... ); // Create an Express app const expressApp = express(); expressApp.use(express.json()); // Set up /mcp endpoint that will be handled by the MCP server expressApp.post('/mcp', async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); res.on('close', () => { transport.close(); }); await mcpServer.connect(transport); await transport.handleRequest(req, res, req.body); }); const port = parseInt(process.env.PORT || '8000'); // Start the Express app expressApp.listen(port, () => { console.log(`MCP Server running on http://localhost:${port}/mcp`); }).on('error', error => { console.error('Server error:', error); process.exit(1); }); Puntos clave : Express app is a generic server that receives communications (like HTTP requests) from the outside (from ChatGPT) Usando Express, agregamos un punto final /mcp que proporcionaremos a ChatGPT como la dirección de nuestro servidor MCP (como https://mysite.com/mcp) The handling of the endpoint is delegated to the MCP server, that runs within the Express app /mcp all of the MCP protocol that we need is handled within that endpoint by the MCP server mcpServer.registerTool(...) y mcpServer.registerResource(...) es lo que usaremos para implementar nuestra aplicación Quiz Herramientas MCP Vamos a llenar la brecha en lugarholder arriba para registrar la “herramienta”. mcpServer.registerTool(…) ChatGPT leerá la definición de la herramienta cuando registremos la aplicación, y luego, cuando el usuario la necesite, ChatGPT invocará la herramienta para iniciar un cuestionario: // Add the tool that receives and validates questions, and starts a quiz mcpServer.registerTool( 'render-quiz', { title: 'Render Quiz', description: ` Use this when the user requests an interactive quiz. The tool expects to receive high-quality single-answer questions that match the schema in input/structuredContent: each item needs { question, options[], correctIndex, explanation }. Use 5–10 questions unless the user requests a specific number of questions. The questions will be shown to the user by the tool as an interactive quiz. Do not print the questions or answers in chat when you use this tool. Do not provide any sensitive or personal user information to this tool.`, _meta: { "openai/outputTemplate": "ui://widget/interactive-quiz.html", // <- hook to the resource }, inputSchema: { topic: z.string().describe("Quiz topic (e.g., 'US history')."), difficulty: z.enum(["easy", "medium", "hard"]).default("medium"), questions: z.array( z.object({ question: z.string(), options: z.array(z.string()).min(4).max(4), correctIndex: z.number().int(), explanation: z.string().optional(), }) ).min(1).max(40), }, }, async (toolInput) => { const { topic, difficulty, questions } = toolInput; // Here you can run any server-side logic to process the input from ChatGPT and // prepare toolOutput that would be fed into the frontend widget code. // E.g. you can receive search filters and return matching items. return { // Optional narration beneath the component content: [{ type: "text", text: `Starting a ${difficulty} quiz on ${topic}.` }], // `structuredContent` will be available as `toolOutput` in the frontend widget code structuredContent: { topic, difficulty, questions, }, // Private to the component; not visible to the model _meta: { "openai/locale": "en" }, }; } ); La mitad superior del código proporciona una descripción de la herramienta - ChatGPT se basará en ella para entender cuándo y cómo usarlo: La descripción describe en detalle lo que hace la herramienta. ChatGPT la utilizará para decidir si la herramienta es aplicable a la solicitud del usuario. inputSchema es una forma de decir a ChatGPT exactamente qué datos necesita proporcionar a la herramienta y cómo debe ser estructurada. Como puede ver arriba, contiene pistas y restricciones que ChatGPT puede utilizar para preparar una carga útil correcta (toolInput). outputSchema se omite aquí, pero puede proporcionarlo para decirle a ChatGPT qué esquema estructuradoContent tendrá. Así que, en cierto sentido, la herramienta es lo que define la aplicación ChatGPT aquí. Veamos los otros dos campos: is the identifier of the MCP resource that the ChatGPT App will use to render the widget. We will look at in the next section below. _meta[“openai/outputTemplate”] async (toolInput) => { ... es la función que recibe toolInput de ChatGPT y produce toolOutput que estará disponible para el widget. Esta es donde podemos ejecutar cualquier lógica del lado del servidor para procesar los datos. En nuestro caso, no necesitamos ningún procesamiento porque toolInput ya contiene toda la información que el widget necesita, por lo que la función devuelve los mismos datos en structuredContent que estarán disponibles como toolOutput al widget. Recursos de MCP A continuación se muestra cómo definimos un recurso MCP: // Add an MCP resource that contains frontend code for rendering the widget mcpServer.registerResource( 'interactive-quiz', "ui://widget/interactive-quiz.html", // must match `openai/outputTemplate` in the tool definition above {}, async (uri) => { // copy frontend script and css const quizaurusJs = await fs.readFile("./src/dist/QuizaurusWidget.js", "utf8"); const quizaurusCss = await fs.readFile("./src/dist/QuizaurusWidget.css", "utf8"); return { contents: [ { uri: uri.href, mimeType: "text/html+skybridge", // Below is the HTML code for the widget. // It defines a root div and injects our custom script from src/dist/QuizaurusWidget.js, // which finds the root div by its ID and renders the widget components in it. text: ` <div id="quizaurus-root" class="quizaurus-root"></div> <script type="module"> ${quizaurusJs} </script> <style> ${quizaurusCss} </style>` } ] } } ); Básicamente, “recurso” aquí proporciona la parte frontend (widget) de la Aplicación. is the resource ID, and it should match of the tool definition from the previous section above. ui://widget/interactive-quiz.html _meta[“openai/outputTemplate”] provides HTML code of the widget contents the HTML here is very simple - we just define the root div and add that will find that root div by ID, create necessary elements (buttons, etc) and define the quiz app logic. We will look at the script in the next section below. quiz-app-root the custom script Building The Widget Implementación de widgets Ahora, vamos a echar un vistazo rápido that implements the widget (the visible part of the app): Escrito por QuizaurusWidget.js // Find the root div defined by the MCP resource const root = document.querySelector('#quiz-app-root'); // create HTML elements inside the root div ... // try to initialize for widgetState to restore the quiz state in case the chat page gets reloaded const selectedAnswers = window.openai.widgetState?.selectedAnswers ?? {}; let currentQuestionIndex = window.openai.widgetState?.currentQuestionIndex ?? 0; function refreshUI() { // Read questions from window.openai.toolOutput - this is the output of the tool defined in server.ts const questions = window.openai.toolOutput?.questions; // Initially the widget will be rendered with empty toolOutput. // It will be populated when ChatGPT receives toolOutput from our tool. if (!questions) { console.log("Questions have not yet been provided. Try again in a few sec.") return; } // Update UI according to the current state ... }; // when an answer button is clicked, we update the state and call refreshUI() optionButtons.forEach((b) => { b.onclick = (event) => { const selectedOption = event.target.textContent selectedAnswers[currentQuestionIndex] = selectedOption; // save and expose selected answers to ChatGPT window.openai.setWidgetState({ selectedAnswers, currentQuestionIndex }); refreshUI(); }; }); ... // at the end of the quiz, the user can click this button to review the answers with ChatGPT reviewResultsButton.onclick = () => { // send a prompt to ChatGPT, it will respond in the chat window.openai.sendFollowUpMessage({ prompt: "Review my answers and explain mistakes" }); reviewResultsButton.disabled = true; }; startQuizButton.onclick = refreshUI; refreshUI(); Recuerda: este código será desencadenado por el HTML que definimos en el recurso MCP anterior (el El HTML y el script estarán dentro de un iframe en la página de chat de ChatGPT. <script type="module">… ChatGPT expone algunos datos y hooks a través de la Objeto global. Aquí está lo que estamos usando aquí: window.openai window.openai.toolOutput contiene los datos de la pregunta devueltos por la herramienta MCP. Inicialmente el html se renderizará antes de que la herramienta devuelva toolOutput, por lo que window.openai.toolOutput estará vacío. Esto es un poco molesto, pero lo arreglaremos más tarde con React. window.openai.widgetState y window.openai.setWidgetState() nos permiten actualizar y acceder al estado del widget. Puede ser cualquier dato que deseemos, aunque la recomendación es mantenerlo por debajo de 4000 tokens. window.openai.sendFollowUpMessage({prompt: “...”}) es una forma de dar un prompt a ChatGPT como si el usuario lo escribiera, y ChatGPT escribirá la respuesta en el chat. Puedes encontrar más capacidades en la documentación de OpenAI aquí: https://developers.openai.com/apps-sdk/build/custom-ux Poniéndolo todo juntos ¡Es hora de probarlo! Un recordatorio rápido, necesitará una suscripción de ChatGPT paga para habilitar el modo de desarrollador. Clone this repo [Download the code] https://github.com/renal128/quizaurus-tutorial There are 2 projects in this repo, a minimalistic one, described above, and a slicker-looking React one. We’ll focus on the first one for now. Open a terminal, navigate to the repo directory and run the following commands: [Starting the server] cd quizaurus-plain install NodeJS if you don’t have it https://nodejs.org/en/download/ to install dependencies defined in package.json npm install to start the Express app with MCP server - npm start keep it running [ ] Expose your local server to the web Create a free ngrok account: https://ngrok.com/ Open a (the other one with the Express app should keep running separately) new terminal Install ngrok: https://ngrok.com/docs/getting-started#1-install-the-ngrok-agent-cli on MacOS brew install ngrok Connect ngrok on your laptop to your ngrok account by configuring it with your auth token: https://ngrok.com/docs/getting-started#2-connect-your-account Start ngrok: ngrok http 8000 You should see something like this in the bottom of the output: Forwarding: https://xxxxx-xxxxxxx-xxxxxxxxx.ngrok-free ngrok created a tunnel from your laptop to a public server, so that your local server is available to everyone on the internet, including ChatGPT. Again, , don’t close the terminal keep it running - this is the part that r , $20/month, otherwise you may not see developer mode available. [Enable Developer Mode on ChatGPT] equires a paid customer subscription Go to ChatGPT website => Settings => Apps & Connectors => Advanced settings Enable the “Developer mode” toggle [Add the app] Go back to “Apps & Connectors” and click “Create” in the top-right corner Fill in the details as on the screenshot. For “MCP Server URL” use the URL that ngrok gave you in the terminal output and . add /mcp to it at the end Click on your newly added app You should see the MCP tool under Actions - now ChatGPT knows when and how to use the app. When you make changes to the code, sometimes , otherwise it can remain cached (sometimes I even delete and re-add the app due to avoid caching). you need to click Refresh to make ChatGPT pick up the changes [ ] Finally, we’re ready to test it! Test the app In the chat window you can nudge ChatGPT to use your app by selecting it under the “ ” button. In my experience, it’s not always necessary, but let’s do it anyway. Then try a prompt like “Make an interactive 3-question quiz about Sam Altman”. + You should see ChatGPT asking your approval to call the MCP tool with the displayed . I assume that it’s a feature for unapproved apps, and it won’t happen once the app is properly reviewed by OpenAI (although, as of Nov 2025 there’s no defined process to publish an app yet). So, just click “Confirm” and wait a few seconds. toolInput As I mentioned above, the widget gets rendered before is returned by our MCP server. This means that if you click “Start Quiz” too soon, it won’t do anything - try again a couple seconds later. (we will fix that with React in the next section below). When the data is ready, clicking “Start Quiz” should show the quiz! toolOutput Usando la reacción Más arriba, miramos el código que utiliza JavaScript simple. El otro proyecto en el mismo repo, , demonstrates how to implement a ChatGPT app using React. Quizaurus-reacción Puedes encontrar una documentación útil de OpenAI aquí: . https://developers.openai.com/apps-sdk/build/custom-ux/ Uso de hooks de ayuda OpenAiGlobal Puedes verlos aquí, el código está copiado de la documentación: https://github.com/renal128/quizaurus-tutorial/blob/main/quizaurus-react/web/src/openAiHooks.ts El más útil es , que le permite suscribirse a la aplicación React a las actualizaciones en Recuerde que en la aplicación simple (no-React) anterior, tuvimos el problema de que el botón “Start Quiz” no estaba haciendo nada hasta que los datos estén listos? useToolOutput window.openai.toolOutput function App() { const toolOutput = useToolOutput() as QuizData | null; if (!toolOutput) { return ( <div className="quiz-container"> <p className="quiz-loading__text">Generating your quiz...</p> </div> ); } // otherwise render the quiz ... } Cuando el output de la herramienta se carga, React re-renderá automáticamente la aplicación y mostrará el cuestionario en lugar del estado de carga. Reacción del router El historial de navegación del iframe en el que se representa la aplicación está conectado al historial de navegación de la página, por lo que puede utilizar APIs de enrutamiento como React Router para implementar la navegación dentro de la aplicación. Otros quirks y características Nota: El desarrollo de la aplicación ChatGPT no es muy estable en este momento, ya que la característica no está totalmente implementada, por lo que es justo esperar cambios no anunciados en la API o errores menores. https://developers.openai.com/apps-sdk Cómo y cuándo ChatGPT decide mostrar su aplicación al usuario The most important part is that your app’s metadata, such as the tool description, must feel relevant to the conversation. ChatGPT’s goal here is to provide the best UX to the user, so obviously if the app’s description is irrelevant to the prompt, the app won’t be shown. I’ve also seen ChatGPT asking the user to rate if the app was helpful or not, I suppose this feedback is also taken into account. App Metadata. Official recommendations: https://developers.openai.com/apps-sdk/guides/optimize-metadata The in order to be used. How would the user know to link an app? There are 2 ways: App Discovery. app needs to be linked/connected to the user’s account Manual - go to and find the app there. Settings => Apps & Connectors Contextual Suggestion - if the app is not connected, but is highly relevant in the conversation, ChatGPT may offer to connect it. I wasn’t able to make it work with my app, but I saw it working with pre-integrated apps like Zillow or Spotify: Once the app is connected, ChatGPT can use it in a conversation when appropriate. The user can nudge it by simply mentioning the app name in the text, typing @AppName or clicking the button and selecting the app in the menu there. Triggering a Connected App. + Plataformas apoyadas Web - ya que se implementa a través de iframe, la web es la plataforma más fácil de soportar y no tuve casi ningún problema allí. Aplicación móvil - si conectas la aplicación en la web, deberías, poder verla en el móvil. no pude activar la aplicación en el móvil - no era capaz de llamar la herramienta, pero cuando desactivé la aplicación en la web, pude interactuar con ella en el móvil. Autenticación ChatGPT Apps admite OAuth 2.1: https://developers.openai.com/apps-sdk/build/auth Este es un gran tema, déjame saber si sería útil escribir un post separado sobre él! Realizar solicitudes de red Esto es lo que dice la documentación ( ) : « Trabajar con su socio OpenAI si necesita dominios específicos permitidos en la lista.” source Standard fetch requests are allowed only when they comply with the CSP En otro lugar ( Esto sugiere la configuración Objeto en la definición de recurso para habilitar sus dominios: Aquí _meta _meta: { ... /* Assigns a subdomain for the HTML. When set, the HTML is rendered within `chatgpt-com.web-sandbox.oaiusercontent.com` It's also used to configure the base url for external links. */ "openai/widgetDomain": 'https://chatgpt.com', /* Required to make external network requests from the HTML code. Also used to validate `openai.openExternal()` requests. */ 'openai/widgetCSP': { // Maps to `connect-src` rule in the iframe CSP connect_domains: ['https://chatgpt.com'], // Maps to style-src, style-src-elem, img-src, font-src, media-src etc. in the iframe CSP resource_domains: ['https://*.oaistatic.com'], } } Otra cosa que puedes usar es el Su widget de aplicación (frontend) puede usarlo para llamar a una herramienta en su servidor MCP - usted proporciona el nombre de la herramienta de MCP y la herramientaInput datos y recibe de vuelta la herramientaOutput: window.openai.callTool await window.openai?.callTool("my_tool_name", { "param_name": "param_value" }); Otras características del frontón Consulte esta documentación para ver lo que está disponible para su código frontend a través de : de window.openai https://developers.openai.com/apps-sdk/build/custom-ux You can access the following fields (e.g. te dirá si ChatGPT está actualmente en el modo luz o oscuro): window.openai.theme theme: Theme; userAgent: UserAgent; locale: string; // layout maxHeight: number; displayMode: DisplayMode; safeArea: SafeArea; // state toolInput: ToolInput; toolOutput: ToolOutput | null; toolResponseMetadata: ToolResponseMetadata | null; widgetState: WidgetState | null; De la misma manera, puede utilizar los siguientes llamados (por ejemplo, intentar Para hacer que tu aplicación sea de pantalla completa): await window.openai?.requestDisplayMode({ mode: "fullscreen" }); /** Calls a tool on your MCP. Returns the full response. */ callTool: ( name: string, args: Record<string, unknown> ) => Promise<CallToolResponse>; /** Triggers a followup turn in the ChatGPT conversation */ sendFollowUpMessage: (args: { prompt: string }) => Promise<void>; /** Opens an external link, redirects web page or mobile app */ openExternal(payload: { href: string }): void; /** For transitioning an app from inline to fullscreen or pip */ requestDisplayMode: (args: { mode: DisplayMode }) => Promise<{ /** * The granted display mode. The host may reject the request. * For mobile, PiP is always coerced to fullscreen. */ mode: DisplayMode; }>; /** Update widget state */ setWidgetState: (state: WidgetState) => Promise<void>; Thank you! Eso es todo, gracias por leer y buena suerte con lo que estés construyendo!