Building ChatGPT apps 

The ChatGPT Apps SDK allows developers to extend ChatGPT with custom functionality by adding new tool calls and UI widgets to the ChatGPT interface. ChatGPT apps require a backend app for running an MCP server and optionally an authorization system that you can use Gadget to host!

Gadget has a built-in ChatGPT connection that provides you with:

  • an MCP server for powering ChatGPT Apps SDK tool calls
  • OAuth 2.1 authentication provider setup for authenticating ChatGPT users with your app's backend
  • the ability to build ChatGPT app widgets that render UX for end users using React, powered by your Gadget app's data

Quick start 

  • Create a new Gadget app at gadget.new
  • Select ChatGPT app type and give your app a name
  • Follow the in-editor instructions to connect to ChatGPT

Building MCP servers 

Gadget apps can host MCP servers that any MCP client can connect to, including OpenAI's ChatGPT Apps SDK.

To setup an MCP server, you can use the @modelcontextprotocol/sdk TypeScript package for rich support for the MCP protocol. You can use HTTP Routes to host the MCP server's endpoints in your Gadget backend.

Gadget recommends building MCP servers using the StreamableHTTP transport approach, not the SSE transport approach. OpenAI's ChatGPT Apps SDK has the support for the StreamableHTTP transport, and it works best in serverless environments like Gadget.

Adding an MCP server to your Gadget app 

First, add the required NPM packages to your Gadget app:

terminal
yarn add @modelcontextprotocol/sdk zod@^3.24.2

Then, create a file that exports a new MCP server instance:

api/mcp.js
JavaScript
import { FastifyRequest } from "fastify"; import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import path from "path"; import { readFileSync } from "fs"; // Make a new MCP server instance for each request export const createMCPServer = async (request: FastifyRequest) => { const mcpServer = new McpServer({ name: "example-mcp", version: "1.0.0", }); mcpServer.registerTool( "sayHello", { title: "say hello", description: "print a hello world message", _meta: { "openai/toolInvocation/invoking": "Mentally prepping to say hello", "openai/toolInvocation/invoked": "Hello has been said", }, }, async () => { return { structuredContent: {}, content: [ { type: "text", text: "Well hello there!", }, ], }; } ); return mcpServer; };
import { FastifyRequest } from "fastify"; import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import path from "path"; import { readFileSync } from "fs"; // Make a new MCP server instance for each request export const createMCPServer = async (request: FastifyRequest) => { const mcpServer = new McpServer({ name: "example-mcp", version: "1.0.0", }); mcpServer.registerTool( "sayHello", { title: "say hello", description: "print a hello world message", _meta: { "openai/toolInvocation/invoking": "Mentally prepping to say hello", "openai/toolInvocation/invoked": "Hello has been said", }, }, async () => { return { structuredContent: {}, content: [ { type: "text", text: "Well hello there!", }, ], }; } ); return mcpServer; };

Then, set up the two required MCP HTTP routes that implement the MCP protocol. The first route implements the MCP protocol using the StreamableHTTP transport. Note that your app's API client is passed into the createMCPServer function so you can use it in your MCP tools to have tenancy-aware access to data:

api/routes/mcp/POST.js
JavaScript
import { RouteHandler } from "gadget-server"; import { createMCPServer } from "../../mcp"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; /** * Route handler for POST /mcp */ const route: RouteHandler = async ({ request, reply, api, logger, connections }) => { const transport = new StreamableHTTPServerTransport({ // run in the stateless mode that doesn't need persistent sessions sessionIdGenerator: undefined, enableJsonResponse: true, }); const server = await createMCPServer(request); // Take control of the response to prevent Fastify from sending its own response // after the MCP transport has already sent one reply.hijack(); try { await server.connect(transport); await transport.handleRequest(request.raw, reply.raw, request.body); } catch (error) { console.error("Failed to start MCP server session", error); if (!reply.raw.headersSent) { reply.raw.writeHead(500).end("Failed to establish MCP server connection"); } } }; route.options = { cors: { // allow requests to the MCP server from any origin (like ChatGPT) origin: true, }, }; export default route;
import { RouteHandler } from "gadget-server"; import { createMCPServer } from "../../mcp"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; /** * Route handler for POST /mcp */ const route: RouteHandler = async ({ request, reply, api, logger, connections }) => { const transport = new StreamableHTTPServerTransport({ // run in the stateless mode that doesn't need persistent sessions sessionIdGenerator: undefined, enableJsonResponse: true, }); const server = await createMCPServer(request); // Take control of the response to prevent Fastify from sending its own response // after the MCP transport has already sent one reply.hijack(); try { await server.connect(transport); await transport.handleRequest(request.raw, reply.raw, request.body); } catch (error) { console.error("Failed to start MCP server session", error); if (!reply.raw.headersSent) { reply.raw.writeHead(500).end("Failed to establish MCP server connection"); } } }; route.options = { cors: { // allow requests to the MCP server from any origin (like ChatGPT) origin: true, }, }; export default route;

And the second route completes the implementation of the MCP protocol by handling GET /mcp requests using the same transport by re-using the same route handler:

api/routes/mcp/GET.js
JavaScript
import { RouteHandler } from "gadget-server"; import Route from "./POST"; // re-use the POST route's implementation of the MCP protocol export default Route;
import { RouteHandler } from "gadget-server"; import Route from "./POST"; // re-use the POST route's implementation of the MCP protocol export default Route;

Your app is now hosting an MCP server at https://<your-app>--development.gadget.app/mcp!

Testing your MCP server 

You can test your MCP server by using the MCP Inspector, or using the ChatGPT app in your browser. Gadget recommends using the MCP Inspector to test your MCP server to start, as it provides a more detailed view of the MCP server's capabilities and better error logging.

To run the MCP Inspector, you can use the following command:

terminal
npx @modelcontextprotocol/inspector --cli https://<your-app>--development.gadget.app/mcp

This will open the MCP Inspector in your browser. You can then use the MCP Inspector to test your MCP server.

Make sure you select the StreamableHTTP transport in the MCP Inspector, as this is the MCP transport style set up by the above HTTP routes.

Adding MCP tools 

You can add tools to your MCP server by using the registerTool method that run server-side JavaScript and return a response to the MCP client. You can read and write data using your Gadget app's api object, make calls to external APIs, or anything else you'd like.

For example, you can add a tool that returns a random number:

api/mcp.js
JavaScript
mcpServer.registerTool( "randomNumber", { title: "random number", description: "return a random number", }, async () => { return { structuredContent: {}, content: [ { type: "text", text: `The random number is ${Math.floor(Math.random() * 100)}`, }, ], }; } );
mcpServer.registerTool( "randomNumber", { title: "random number", description: "return a random number", }, async () => { return { structuredContent: {}, content: [ { type: "text", text: `The random number is ${Math.floor(Math.random() * 100)}`, }, ], }; } );

You can also build responses that read data from your Gadget app's database:

api/mcp.js
JavaScript
mcpServer.registerTool( "listTodos", { title: "list todos", description: "list all todos", }, async () => { // use request.api.actAsSession for session-aware data access (for multi-tenant apps) const todos = await request.api.actAsSession.todo.findMany({ first: 10 }); return { structuredContent: { todos: todos.map((todo) => ({ id: todo.id, title: todo.title, })), }, content: [ { type: "text", text: `The todos are ${todos.map((todo) => todo.title).join(", ")}`, }, ], }; } );
mcpServer.registerTool( "listTodos", { title: "list todos", description: "list all todos", }, async () => { // use request.api.actAsSession for session-aware data access (for multi-tenant apps) const todos = await request.api.actAsSession.todo.findMany({ first: 10 }); return { structuredContent: { todos: todos.map((todo) => ({ id: todo.id, title: todo.title, })), }, content: [ { type: "text", text: `The todos are ${todos.map((todo) => todo.title).join(", ")}`, }, ], }; } );

Use your app's API client in MCP tools 

You can use your app's API client in MCP tools to read and write data in a tenancy-aware way. When you use window.openai.callTool to make tool calls to your MCP server, OpenAI passes the OAuth token (as Authentication: Bearer <token>).

This means request.api.actAsSession is automatically scoped to the authenticated user, and you can use it to read and write data on behalf of the authenticated user.

For example, we can add a tool that creates a new todo item for the authenticated user:

api.mcp.js
JavaScript
export const createMCPServer = async (request: FastifyRequest) => { const mcpServer = new McpServer({ name: "chatgpt-todo-list", version: "1.0.0", }); // a session-aware API client for multi-tenant reads and writes const api = request.api.actAsSession; mcpServer.registerTool( "addTodo", { title: "add a todo", description: "add a new todo item to my list", inputSchema: { item: z.string() }, _meta: { "openai/widgetAccessible": true, }, }, async ({ item }) => { // use the api client to create a new todo record const todo = await api.todo.create({ item }); const output = { todo }; return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output, }; } ); };
export const createMCPServer = async (request: FastifyRequest) => { const mcpServer = new McpServer({ name: "chatgpt-todo-list", version: "1.0.0", }); // a session-aware API client for multi-tenant reads and writes const api = request.api.actAsSession; mcpServer.registerTool( "addTodo", { title: "add a todo", description: "add a new todo item to my list", inputSchema: { item: z.string() }, _meta: { "openai/widgetAccessible": true, }, }, async ({ item }) => { // use the api client to create a new todo record const todo = await api.todo.create({ item }); const output = { todo }; return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output, }; } ); };

Adding ChatGPT app widgets 

ChatGPT apps can render UI widgets in the responses to users, showing them rich UI in addition to plain text responses. You can use Gadget to build these widgets using React code and components in your Gadget app's web folder. Gadget will automatically generate widget resource bundles for the ChatGPT Apps SDK to use, complete with hot module reloading and easy bundling for production.

A ChatGPT Apps SDK widget is an HTML file, served up to the ChatGPT Apps SDK as an ui:// resource by your MCP server. You can't serve widgets as normal HTML files over the internet, and instead you must expose them as resources on your MCP server. You must also set up CORS on your MCP server to allow the ChatGPT web sandbox to access your widgets source code cross-origin.

Installing the required packages 

Gadget apps hosting ChatGPT app widgets need to install the vite-plugin-chatgpt-widgets and @gadgetinc/react-chatgpt-apps packages:

terminal
yarn add vite-plugin-chatgpt-widgets @gadgetinc/react-chatgpt-apps

Then, add the vite-plugin-chatgpt-widgets plugin and set the required server.cors properties in your Vite configuration:

vite.config.mjs
JavaScript
import { defineConfig } from "vite"; import { gadget } from "gadget-server/vite"; import { reactRouter } from "@react-router/dev/vite"; import path from "path"; import { chatGPTWidgetPlugin } from "vite-plugin-chatgpt-widgets"; export default defineConfig({ plugins: [ gadget(), reactRouter(), // mount the chatGPTWidgetPlugin to your Vite config chatGPTWidgetPlugin({ baseUrl: process.env["GADGET_APP_URL"], }), ], resolve: { alias: { "@": path.resolve(__dirname, "./web"), }, }, server: { cors: { // ensure both the Gadget app and the ChatGPT web sandbox can access your widgets source code cross-origin origin: [ process.env["GADGET_APP_URL"]!, "https://web-sandbox.oaiusercontent.com", ], }, }, });
import { defineConfig } from "vite"; import { gadget } from "gadget-server/vite"; import { reactRouter } from "@react-router/dev/vite"; import path from "path"; import { chatGPTWidgetPlugin } from "vite-plugin-chatgpt-widgets"; export default defineConfig({ plugins: [ gadget(), reactRouter(), // mount the chatGPTWidgetPlugin to your Vite config chatGPTWidgetPlugin({ baseUrl: process.env["GADGET_APP_URL"], }), ], resolve: { alias: { "@": path.resolve(__dirname, "./web"), }, }, server: { cors: { // ensure both the Gadget app and the ChatGPT web sandbox can access your widgets source code cross-origin origin: [ process.env["GADGET_APP_URL"]!, "https://web-sandbox.oaiusercontent.com", ], }, }, });

Once the vite plugin is installed, you can add all the built widgets to your MCP server's resources:

api/mcp.js
JavaScript
import { getWidgets } from "vite-plugin-chatgpt-widgets"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { FastifyRequest } from "fastify"; import path from "path"; import { getViteHandle } from "gadget-server/vite"; export const createMCPServer = async (request: FastifyRequest) => { const mcpServer = new McpServer({ name: "example-mcp", version: "1.0.0", }); // Get the vite dev server instance from Gadget const devServer = await ( request.server as any ).frontendServerManager?.devServerManager?.getServer(); const viteHandle = await getViteHandle(request.server); // Get the HTML snippet for each widget const widgets = await getWidgets("web/chatgpt", viteHandle); // Register each widget's HTML snippet as a resource for exposure to ChatGPT for (const widget of widgets) { const resourceName = `widget-${widget.name.toLowerCase()}`; const resourceUri = `ui://widget/${widget.name}.html`; mcpServer.registerResource( resourceName, resourceUri, { title: widget.name, description: `ChatGPT widget for ${widget.name}`, }, async () => { return { contents: [ { uri: resourceUri, mimeType: "text/html+skybridge", text: widget.content, }, ], }; } ); } // Set up the required auth tool for authenticating API calls from your client side widgets // don't remove this if you are using the `api` object client side in your widget React code! mcpServer.registerTool( "__getGadgetAuthTokenV1", { title: "Get the gadget auth token", description: "Gets the gadget auth token. Should never be called by LLMs or ChatGPT -- only used for internal auth machinery.", _meta: { // ensure widgets can invoke this tool to get the token "openai/widgetAccessible": true, }, }, async () => { if (!request.headers["authorization"]) { return { structuredContent: { token: null, error: "no token found", }, content: [], }; } const [scheme, token] = request.headers["authorization"].split(" ", 2); if (scheme !== "Bearer") { return { structuredContent: { token: null, error: "incorrect token scheme", }, content: [], }; } return { structuredContent: { token, scheme, }, content: [], }; } ); // set up tools, prompts, etc return mcpServer; };
import { getWidgets } from "vite-plugin-chatgpt-widgets"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { FastifyRequest } from "fastify"; import path from "path"; import { getViteHandle } from "gadget-server/vite"; export const createMCPServer = async (request: FastifyRequest) => { const mcpServer = new McpServer({ name: "example-mcp", version: "1.0.0", }); // Get the vite dev server instance from Gadget const devServer = await ( request.server as any ).frontendServerManager?.devServerManager?.getServer(); const viteHandle = await getViteHandle(request.server); // Get the HTML snippet for each widget const widgets = await getWidgets("web/chatgpt", viteHandle); // Register each widget's HTML snippet as a resource for exposure to ChatGPT for (const widget of widgets) { const resourceName = `widget-${widget.name.toLowerCase()}`; const resourceUri = `ui://widget/${widget.name}.html`; mcpServer.registerResource( resourceName, resourceUri, { title: widget.name, description: `ChatGPT widget for ${widget.name}`, }, async () => { return { contents: [ { uri: resourceUri, mimeType: "text/html+skybridge", text: widget.content, }, ], }; } ); } // Set up the required auth tool for authenticating API calls from your client side widgets // don't remove this if you are using the `api` object client side in your widget React code! mcpServer.registerTool( "__getGadgetAuthTokenV1", { title: "Get the gadget auth token", description: "Gets the gadget auth token. Should never be called by LLMs or ChatGPT -- only used for internal auth machinery.", _meta: { // ensure widgets can invoke this tool to get the token "openai/widgetAccessible": true, }, }, async () => { if (!request.headers["authorization"]) { return { structuredContent: { token: null, error: "no token found", }, content: [], }; } const [scheme, token] = request.headers["authorization"].split(" ", 2); if (scheme !== "Bearer") { return { structuredContent: { token: null, error: "incorrect token scheme", }, content: [], }; } return { structuredContent: { token, scheme, }, content: [], }; } ); // set up tools, prompts, etc return mcpServer; };

And then finally, you can add a base template for all your widgets that sets up the right React context for your widgets to render:

web/chatgpt/root.jsx
JavaScript
import { Provider as GadgetProvider } from "@gadgetinc/react-chatgpt-apps"; import { api } from "../api"; // import our main app.css file so we inherit all styles that the main frontend is using import "../app.css"; export const ChatGPTWidgetRoot = ({ children }: { children: React.ReactNode }) => { // render within the GadgetProvider context so react hooks like `useFindMany` and `useFetch` work in ChatGPT widgets return <GadgetProvider api={api}>{children}</GadgetProvider>; }; export default ChatGPTWidgetRoot;
import { Provider as GadgetProvider } from "@gadgetinc/react-chatgpt-apps"; import { api } from "../api"; // import our main app.css file so we inherit all styles that the main frontend is using import "../app.css"; export const ChatGPTWidgetRoot = ({ children }: { children: React.ReactNode }) => { // render within the GadgetProvider context so react hooks like `useFindMany` and `useFetch` work in ChatGPT widgets return <GadgetProvider api={api}>{children}</GadgetProvider>; }; export default ChatGPTWidgetRoot;

Adding a new widget 

Once vite-plugin-chatgpt-widgets is installed and configured, you can add ChatGPT app widgets to your Gadget app's web/chatgpt folder. Each file in this folder will be automatically bundled and made available to the ChatGPT Apps SDK.

For example, you can add a widget that displays a "Hello World" message, and then use it in a ChatGPT app's response to a tool call:

web/chatgpt/HelloWorld.jsx
JavaScript
const HelloWorldWidget = () => { return ( <div> <h1>Hello World</h1> </div> ); }; export default HelloWorldWidget;
const HelloWorldWidget = () => { return ( <div> <h1>Hello World</h1> </div> ); }; export default HelloWorldWidget;
api/mcp.js
JavaScript
mcpServer.registerTool( "helloWorld", { title: "hello world", description: "display a hello world message", _meta: { // tell ChatGPT to render the web/chatgpt/HelloWorld.tsx widget when this tool is invoked "openai/outputTemplate": "ui://widget/HelloWorld.html", }, }, async () => { return { structuredContent: {}, content: [ { type: "text", text: "Hello World", }, ], }; } );
mcpServer.registerTool( "helloWorld", { title: "hello world", description: "display a hello world message", _meta: { // tell ChatGPT to render the web/chatgpt/HelloWorld.tsx widget when this tool is invoked "openai/outputTemplate": "ui://widget/HelloWorld.html", }, }, async () => { return { structuredContent: {}, content: [ { type: "text", text: "Hello World", }, ], }; } );

Fetching backend data 

Widgets can fetch backend data using the normal React hooks from @gadgetinc/react.

For example, you can fetch a list of todos from your Gadget API in a widget using the useFindMany hook:

web/chatgpt/TodoList.jsx
React
import { useFindMany } from "@gadgetinc/react"; import { api } from "../api"; const TodoList = () => { // use the useFindMany hook to fetch the todos via the app's GraphQL API instead of an MCP tool call const [{ data, fetching, error }, refresh] = useFindMany(api.todo); return ( <ul> {data.map((todo) => ( <li key={todo.id}>{todo.title}</li> ))} </ul> ); }; export default TodoList;
import { useFindMany } from "@gadgetinc/react"; import { api } from "../api"; const TodoList = () => { // use the useFindMany hook to fetch the todos via the app's GraphQL API instead of an MCP tool call const [{ data, fetching, error }, refresh] = useFindMany(api.todo); return ( <ul> {data.map((todo) => ( <li key={todo.id}>{todo.title}</li> ))} </ul> ); }; export default TodoList;

Or you can add a form for creating a new todo using the useAction hook:

web/chatgpt/CreateTodoForm.jsx
React
import { useAction } from "@gadgetinc/react"; import { api } from "../api"; const CreateTodoForm = () => { // setup an action we can call when the form submits const [{ data, fetching, error }, createTodo] = useAction(api.todo.create); const [title, setTitle] = useState(""); return ( <form onSubmit={() => createTodo({ title })}> <input type="text" name="title" value={title} onChange={(e) => setTitle(e.target.value)} /> <button type="submit">Create Todo</button> </form> ); }; export default CreateTodoForm;
import { useAction } from "@gadgetinc/react"; import { api } from "../api"; const CreateTodoForm = () => { // setup an action we can call when the form submits const [{ data, fetching, error }, createTodo] = useAction(api.todo.create); const [title, setTitle] = useState(""); return ( <form onSubmit={() => createTodo({ title })}> <input type="text" name="title" value={title} onChange={(e) => setTitle(e.target.value)} /> <button type="submit">Create Todo</button> </form> ); }; export default CreateTodoForm;

Read more about the React hooks from @gadgetinc/react in the frontend guide.

Widget authentication 

When a user installs your ChatGPT app, they will authenticate themselves with your Gadget backend, and establish an identity in your backend session or user model. When you make API calls to your Gadget API, or use MCP server tools, Gadget will activate the ChatGPT user's identity for those calls. That means that your Gadget app's normal access control checks will apply to reads and writes made by the ChatGPT user from within widget.

Read more about access control in the Access Control guide.

Using without authentication 

If you don't need authentication for the API calls made by your ChatGPT widgets such that they are safe to be world readable, you can disable authentication in the provider by setting the authenticate prop to false:

web/chatgpt/root.jsx
JavaScript
import { Provider } from "@gadgetinc/react-chatgpt-apps"; import { api } from "../api"; function App() { return ( <Provider api={api} authenticate={false}> <YourChatGPTAppComponents /> </Provider> ); } export default App;
import { Provider } from "@gadgetinc/react-chatgpt-apps"; import { api } from "../api"; function App() { return ( <Provider api={api} authenticate={false}> <YourChatGPTAppComponents /> </Provider> ); } export default App;

React hooks for the window.openai API 

The window.openai API is the bridge between you frontend and ChatGPT. It provides data and functions you can use in your app to do things like:

  • hydrate your widget's initial state
  • save a widgets state so it is preserved between refreshes
  • make additional tool calls
  • handle different display modes

Gadget provides hooks you can use to interact with window.openai. Documentation for these hooks can be found in the @gadgetinc/react-chatgpt-apps reference.

More information about window.openai can be found in the Apps SDK docs.

Built-in OAuth authentication 

When you add the ChatGPT Apps connection to your Gadget app, your app automatically becomes an OIDC-compliant authorization server powered by the node-oidc-provider library. This means ChatGPT users can securely authenticate with your backend, establishing an identity that can be used for access control and personalization.

The only configuration required is specifying which route users will be directed to when granting consent. By default, this is the /authorize route, which is powered by the web/routes/authorize.tsx file that Gadget generates for you when you add the connection.

For more details on how to customize the OAuth flow and integrate it with your app's authentication system, see the Gadget as an OAuth Provider section below.

Gadget as an OAuth Provider 

Gadget automatically acts as a spec-compliant OAuth 2.1 / OIDC authorization server when your app has an active ChatGPT connection. This means your app can authenticate users from external services like ChatGPT without you having to implement the OAuth protocol yourself.

How it works 

The OAuth flow in Gadget follows the standard OAuth 2.1 protocol with PKCE (Proof Key for Code Exchange):

  1. A user attempts to install or connect your ChatGPT app
  2. ChatGPT initiates an OAuth flow by redirecting the user to your Gadget app's authorization endpoint
  3. Gadget redirects the user to your configured authorization route (by default /authorize)
  4. Your app presents a consent screen and authenticates the user
  5. Once the user grants consent, your app posts to Gadget's OAuth endpoint
  6. Gadget runs any configured install actions on your session and user models
  7. If successful, Gadget completes the OAuth flow and issues tokens to ChatGPT
  8. ChatGPT can now make authenticated requests to your app on behalf of the user

Configuring the authorization route 

You need to specify a route where users will be taken to consent to the OAuth grant. This is configured in the ChatGPT connection settings in the Gadget editor.

The authorization route can be:

  • A frontend route (recommended) - defined using React Router's framework mode, which provides a better user experience with full access to React components and hooks
  • A backend route - if you prefer to render HTML directly from your backend

When a user is directed to your authorization route, the following query parameters will be present:

ParameterDescription
codeA short-lived code needed to complete the OAuth flow. Note: This is NOT the OAuth authorization code (that gets issued automatically by Gadget).
promptEither "consent" or "none" - indicates whether user interaction is required.
scopeThe scopes that the client is requesting access to.

Implementing the authorization route 

Your authorization route is responsible for:

  1. Authenticating the user - determining who is granting access
  2. Establishing a session - creating or updating the session that will be associated with the OAuth grant
  3. Presenting consent UI - showing the user what they're granting access to
  4. Posting to the authorization endpoint - completing the OAuth flow

Here's the default web/routes/authorize.tsx file that Gadget generates for you:

web/routes/authorize.jsx
React
import { api } from "../api"; import { redirect } from "react-router"; import type { Route } from "./+types/authorize"; export const loader = async ({ context, request }: Route.LoaderArgs) => { const url = new URL(request.url); const code = url.searchParams.get("code"); if (!code) { throw new Error("Missing required OAuth parameter: code"); } // If you have a user model, you can check if the user is logged in const { session, gadgetConfig } = context; const userId = session?.get("user"); const user = userId ? await context.api.user.findOne(userId) : undefined; // If no user is logged in, redirect to sign in if (!user) { const redirectTo = `${url.pathname}${url.search}`; return redirect(`${gadgetConfig.authentication!.signInPath}?redirectTo=${encodeURIComponent(redirectTo)}`); } return { csrfToken: session?.get("csrfToken"), user, code, }; }; export default function Authorize({ loaderData }: Route.ComponentProps) { const { csrfToken, user, code } = loaderData; return ( <div className="consent-screen"> <h1>Authorize Access</h1> <p>ChatGPT would like to access your account.</p> {/* Form that posts to the OAuth endpoint */} <form method="POST" action={`/api/oauth/v2/interaction/${code}/authorize`}> <input type="hidden" name="csrfToken" value={csrfToken} /> <button type="submit">Allow Access</button> </form> {user.email && <p>Logged in as {user.email}</p>} </div> ); }
import { api } from "../api"; import { redirect } from "react-router"; import type { Route } from "./+types/authorize"; export const loader = async ({ context, request }: Route.LoaderArgs) => { const url = new URL(request.url); const code = url.searchParams.get("code"); if (!code) { throw new Error("Missing required OAuth parameter: code"); } // If you have a user model, you can check if the user is logged in const { session, gadgetConfig } = context; const userId = session?.get("user"); const user = userId ? await context.api.user.findOne(userId) : undefined; // If no user is logged in, redirect to sign in if (!user) { const redirectTo = `${url.pathname}${url.search}`; return redirect(`${gadgetConfig.authentication!.signInPath}?redirectTo=${encodeURIComponent(redirectTo)}`); } return { csrfToken: session?.get("csrfToken"), user, code, }; }; export default function Authorize({ loaderData }: Route.ComponentProps) { const { csrfToken, user, code } = loaderData; return ( <div className="consent-screen"> <h1>Authorize Access</h1> <p>ChatGPT would like to access your account.</p> {/* Form that posts to the OAuth endpoint */} <form method="POST" action={`/api/oauth/v2/interaction/${code}/authorize`}> <input type="hidden" name="csrfToken" value={csrfToken} /> <button type="submit">Allow Access</button> </form> {user.email && <p>Logged in as {user.email}</p>} </div> ); }

Working without a user model 

Not all apps need a dedicated user model. Some apps may want to authorize access on a per-session basis, allowing each ChatGPT session to have its own isolated data without requiring user accounts.

In this case, you can skip the authentication check and simply use the session that Gadget automatically provides:

web/routes/authorize.jsx
React
export const loader = async ({ context, request }: Route.LoaderArgs) => { const url = new URL(request.url); const code = url.searchParams.get("code"); if (!code) { throw new Error("Missing required OAuth parameter: code"); } // Just use the session directly, no user required return { csrfToken: context.session?.get("csrfToken"), code, }; }; export default function Authorize({ loaderData }: Route.ComponentProps) { const { csrfToken, code } = loaderData; return ( <div className="consent-screen"> <h1>Authorize Access</h1> <p>ChatGPT would like to access this app.</p> <form method="POST" action={`/api/oauth/v2/interaction/${code}/authorize`}> <input type="hidden" name="csrfToken" value={csrfToken} /> <button type="submit">Allow Access</button> </form> </div> ); }
export const loader = async ({ context, request }: Route.LoaderArgs) => { const url = new URL(request.url); const code = url.searchParams.get("code"); if (!code) { throw new Error("Missing required OAuth parameter: code"); } // Just use the session directly, no user required return { csrfToken: context.session?.get("csrfToken"), code, }; }; export default function Authorize({ loaderData }: Route.ComponentProps) { const { csrfToken, code } = loaderData; return ( <div className="consent-screen"> <h1>Authorize Access</h1> <p>ChatGPT would like to access this app.</p> <form method="POST" action={`/api/oauth/v2/interaction/${code}/authorize`}> <input type="hidden" name="csrfToken" value={csrfToken} /> <button type="submit">Allow Access</button> </form> </div> ); }

Completing the OAuth flow 

Once you've authenticated the user and they've granted consent, you need to POST to:

/api/oauth/v2/interaction/${code}/authorize

Where code is the parameter from the query string. This tells Gadget to proceed with the OAuth flow.

Make sure to include the CSRF token in your POST request to prevent cross-site request forgery attacks. The CSRF token is available in the session as csrfToken.

Authentication tokens and session management 

After the OAuth code exchange is complete, Gadget issues an authentication token that ChatGPT will use for all subsequent requests to your app. This token is a symmetrically signed JWT that contains the session information established during the authorization flow.

ChatGPT includes this token as a Bearer token in the Authorization header for all requests (made via tool calls) to your MCP server:

Authorization: Bearer <jwt-token>

When Gadget receives a request with this token, it automatically:

  1. Restores the session - Sets the request's session to be the same session that was established during the initial OAuth flow
  2. Activates the user context - If a user was signed in during authorization, all ChatGPT requests to your MCP server will have that user as the authenticated tenant

This means that when you use request.api.actAsSession in your MCP tools (as shown in the MCP server examples), the API client is automatically scoped to the authenticated user who authorized the connection. Your app's access control rules and tenant isolation will work exactly as expected, with each ChatGPT user only able to access their own data.

This token is also used to make requests from your widget using your app's API client, thanks to the Provider set up in web/chatgpt/root.tsx. See the @gadgetinc/react-chatgpt-app reference for more details.

For example, if a user with ID "123" authorizes your ChatGPT app, all subsequent MCP tool calls will execute with that user's session and identity:

api/mcp.js
JavaScript
mcpServer.registerTool( "getTodos", { title: "get user's todos", description: "fetch todo data for the authenticated user", }, async () => { // This API call is automatically scoped to the authenticated user // who authorized the ChatGPT app - no need to manually pass user IDs! // the todos will only be those that belong to the authenticated user const todos = await request.api.actAsSession.todo.findMany(); return { structuredContent: { data }, content: [{ type: "text", text: JSON.stringify(todos) }], }; } );
mcpServer.registerTool( "getTodos", { title: "get user's todos", description: "fetch todo data for the authenticated user", }, async () => { // This API call is automatically scoped to the authenticated user // who authorized the ChatGPT app - no need to manually pass user IDs! // the todos will only be those that belong to the authenticated user const todos = await request.api.actAsSession.todo.findMany(); return { structuredContent: { data }, content: [{ type: "text", text: JSON.stringify(todos) }], }; } );

The JWT token is symmetrically signed, meaning Gadget uses a shared secret to both create and verify the token. This ensures that only your Gadget app can issue and validate tokens for your OAuth implementation.

Running actions on install 

Before completing the OAuth flow, Gadget will look for any actions on your session model (and user model if it exists) that have the ChatGPT App Install trigger configured. These actions will be called automatically, allowing you to:

  • Set up initial data for the user
  • Record analytics about the installation
  • Validate that the user is allowed to connect
  • Perform any other custom logic

By default, no actions have this trigger configured. You need to add it manually if you want to run custom code.

To add the ChatGPT App Install trigger to an action:

  1. Navigate to the action you want to configure (e.g., session/update or user/update)
  2. Click on the Triggers tab
  3. Add the ChatGPT App Install trigger

The action will receive a payload with information about the OAuth request:

api/models/session/actions/update.js
JavaScript
export const onSuccess: ActionOnSuccess = async ({ params, record, trigger }) => { if (trigger?.type === "chatgpt_app_install") { // Access OAuth information from the trigger const { oauth, userId, sessionId } = trigger; // oauth.scope - the scopes being granted // oauth.clientId - the OAuth client ID // userId - the user ID (if user model exists) // sessionId - the session ID // Perform any setup needed for this installation await api.yourModel.create({ session: { _link: sessionId }, // ... other fields }); } };
export const onSuccess: ActionOnSuccess = async ({ params, record, trigger }) => { if (trigger?.type === "chatgpt_app_install") { // Access OAuth information from the trigger const { oauth, userId, sessionId } = trigger; // oauth.scope - the scopes being granted // oauth.clientId - the OAuth client ID // userId - the user ID (if user model exists) // sessionId - the session ID // Perform any setup needed for this installation await api.yourModel.create({ session: { _link: sessionId }, // ... other fields }); } };

If any of your install actions throw an error, the entire OAuth flow will be aborted and the user will not be able to connect. Make sure your actions handle errors gracefully!

Dynamic client registration 

Gadget's OAuth implementation supports dynamic client registration as specified in the OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591). This means OAuth clients like ChatGPT can register themselves automatically without requiring manual configuration in your Gadget app.

When a new client attempts to connect, Gadget will automatically register it and allow the OAuth flow to proceed. This makes it easy to connect multiple OAuth clients to your app without additional setup.

OAuth endpoints 

Gadget exposes the following OAuth endpoints for your app:

EndpointDescription
/api/oauth/v2/authOAuth authorization endpoint - where OAuth clients begin the flow
/api/oauth/v2/tokenToken endpoint - where clients exchange authorization codes for access tokens
/api/oauth/v2/regDynamic Client Registration endpoint - where OAuth clients can register themselves automatically
/api/oauth/v2/interaction/:codeInteraction details - used internally by the authorization flow
/api/oauth/v2/interaction/:code/authorizeAuthorization completion - where your app posts to complete the flow
/.well-known/openid-configurationOpenID Connect discovery document - provides OIDC configuration metadata
/.well-known/oauth-protected-resourceOAuth 2.0 Protected Resource Metadata - provides information about the protected resource endpoints
/.well-known/oauth-authorization-serverOAuth 2.0 Authorization Server Metadata - provides OAuth server configuration and capabilities

These endpoints are fully managed by Gadget and implement the OAuth 2.1 and OIDC specifications.

Customizing the OAuth configuration 

The OAuth server configuration is managed automatically by Gadget based on your ChatGPT connection settings. The main customization you need to make is specifying the authorization route where users grant consent.

You can update this in the Gadget editor:

  1. Navigate to SettingsConnectionsChatGPT
  2. Update the Authorization Path field
  3. Deploy your changes

The authorization path must be a route in your Gadget app. It can be either a frontend route or a backend HTTP route.

Was this page helpful?