How to Build a ChatGPT App with the OpenAI Apps SDK

Introduction

You built a genuinely useful tool, with an API, a database, and users who love it. Then someone asks the question that kills the demo: can I just use it inside ChatGPT, where I already work? OpenAI fixed that gap with the Apps SDK, which turns your service into an app that lives inside ChatGPT, callable by conversation and renderable as a real interface. This tutorial builds one end to end: a small reading-list app with an MCP server, a widget, local testing, and the full submission package.

Checked on 2026-11-09 against the OpenAI Apps SDK build guide and the plugin submission guide. Interfaces change fast, so if a label differs, trust the linked page.

Quick answer

Build a ChatGPT app by creating an MCP server with the TypeScript SDK, registering focused tools with clear descriptions and safety annotations, optionally attaching an HTML widget, testing it, then submitting the plugin ZIP for review.

The hard part is not the code, it is packaging and review: tool descriptions, annotations, domain verification, and test cases.

Evidence

ChatGPT passed 800 million weekly active users at DevDay 2026, and OpenAI said 4 million developers had built on its platform. That audience is why the app directory matters: a plugin is distribution to people who never visit your website. OpenAI launched the Apps SDK in preview at DevDay in October 2025 and opened app submissions and the in-ChatGPT directory that December. At DevDay on 29 September 2026 it folded apps into a plugin system shared by ChatGPT and Codex. The docs now call the result a plugin, but the stack is unchanged: an MCP server with optional UI.

One rule from the official build guide shapes every design decision below: “Enforce authorization in the MCP server for every request; never rely on the model to decide whether a user has access.” Your server is the security boundary, not the model, and reviewers check for it.

User asks in ChatGPT
        |
        v
ChatGPT model reads tool list
        |
        v
MCP server (your code, public HTTPS)
   - list_links, save_link  (data tools)
   - render_links_widget    (render tool)
        |
        v
Widget iframe renders the list
        |
        v
User clicks, widget calls tools back

Prerequisites

  • Node.js 20 or newer and npm. Check with node --version.
  • A ChatGPT account and an OpenAI Platform account with a verified identity.
  • A domain or subdomain you control, with HTTPS, for the production endpoint and verification.
  • Basic TypeScript. The Python mcp package works too; see our Python MCP server tutorial.

Step-by-step guide

Step 1: Pick one job, not a platform

OpenAI’s build guide starts from a use-case inventory, not from code: each tool should complete a recognizable user goal and expose only the data and actions that goal needs. Our example app does one job, saving and listing reading links, because a single sharp job survives review. Plugins that duplicate a built-in ChatGPT feature are rejected, so check first that ChatGPT cannot already do your job on its own. Write down three test prompts before you write code: one direct request, one edge case, and one your app should refuse. You will reuse them as submission test cases.

Step 2: Scaffold the MCP server

Create the project and install the official SDK, published as @modelcontextprotocol/sdk, with zod for schemas.

mkdir reading-list-app && cd reading-list-app
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node @types/express express
npx tsc --init

Create src/server.ts with a named, versioned server, and keep the name and version stable once published.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

const server = new McpServer(
  { name: "reading-list", version: "1.0.0" },
  {
    instructions:
      "Before saving a link, call list_links to check whether the URL is already saved.",
  }
);

Server instructions apply across tools; keep the most important details in the first 512 characters. Do not repeat tool descriptions there, and do not try to change the model’s personality.

Step 3: Register focused tools with honest annotations

One tool per distinct action. Our app needs two data tools, save_link and list_links; the render tool comes in Step 5. The example data below is labelled: Example, replace with your values.

import { z } from "zod";

// Example — replace with your values: swap this array for your database.
const links = [
  { id: "link-1", url: "https://example.com/mcp-guide", title: "MCP Guide", read: false },
  { id: "link-2", url: "https://example.com/apps-sdk", title: "Apps SDK Notes", read: true },
];

server.registerTool(
  "list_links",
  {
    title: "List saved links",
    description:
      "Use this when the user wants to review links they saved to their reading list.",
    inputSchema: {
      unread_only: z.boolean().optional(),
    },
    outputSchema: {
      links: z.array(
        z.object({
          id: z.string(),
          url: z.string(),
          title: z.string(),
          read: z.boolean(),
        })
      ),
    },
    annotations: {
      readOnlyHint: true,
      openWorldHint: false,
      destructiveHint: false,
    },
  },
  async ({ unread_only }) => {
    const result = links.filter((link) => !unread_only || !link.read);
    return {
      structuredContent: { links: result },
      content: [{ type: "text", text: `Found ${result.length} saved links.` }],
    };
  }
);

Three details matter, and reviewers check all of them. First, the description says when to use the tool, because the model uses it to decide whether to call it. Second, structuredContent carries concise data for later calls, while content carries the human-readable text. Third, the annotations must match reality: readOnlyHint is true only when the tool cannot change state, destructiveHint is true for irreversible outcomes, and openWorldHint is true when the tool touches the public internet. A tool limited to a bounded private workspace can set openWorldHint to false even when hosted externally.

Add save_link the same way, with readOnlyHint: false, since it changes state. Never put secrets, access tokens, or unnecessary personal data in tool results, and treat _meta as hidden from the model, not as a substitute for authorization.

Step 4: Expose the server over streamable HTTP and test it

A ChatGPT app server exposes a streamable HTTP endpoint, typically at /mcp. Wire the SDK’s streamable HTTP transport into Express and listen on port 3000, following the TypeScript SDK’s documented stateless pattern.

import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

const app = express();
app.use(express.json());

app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3000, () => {
  console.log("MCP server listening on http://localhost:3000/mcp");
});

Start it with npx tsx src/server.ts, then inspect it with the official MCP Inspector before ChatGPT sees it:

npx @modelcontextprotocol/inspector

In the Inspector UI, select Streamable HTTP and enter http://localhost:3000/mcp. Run every tool with representative and invalid inputs, and confirm initialization succeeds, the tool list looks right, and authorization is enforced for private data and writes. Our MCP server tutorial explains the same inspect-first habit in more depth. Fixing a broken tool here costs minutes; fixing it after submission costs a review cycle.

Step 5: Add the widget with the decoupled pattern

Custom UI is optional. Add it only when people need to inspect, compare, edit, or confirm structured information, and keep the tools useful without it, so ChatGPT can complete the workflow in clients that do not render components. The widget is an HTML resource that runs inside an iframe in ChatGPT and talks to the host through the MCP Apps bridge, JSON-RPC over postMessage.

The pattern that works best is decoupled: data tools fetch and mutate, and a separate render tool returns the widget. If you attach a widget template to every tool call, ChatGPT can re-render your iframe too often. Only the render tool carries the template link, in _meta.ui.resourceUri; the older alias is _meta["openai/outputTemplate"].

const TEMPLATE_URI = "ui://widget/links.html";

server.registerResource("links-widget", TEMPLATE_URI, {}, async () => ({
  contents: [
    {
      uri: TEMPLATE_URI,
      mimeType: "text/html;profile=mcp-app",
      text: `<!doctype html><html lang="en"><body>
        <div id="list">Loading your reading list...</div>
        <script type="module">
          import { App } from "@modelcontextprotocol/ext-apps";
          const app = new App({ name: "Links widget", version: "1.0.0" });
          app.ontoolresult = (result) => {
            const links = result.structuredContent?.links ?? [];
            document.getElementById("list").innerHTML =
              links.map((l) => "<p><a href='" + l.url + "'>" + l.title + "</a></p>").join("") ||
              "<p>No saved links yet.</p>";
          };
          await app.connect();
        </script>
      </body></html>`,
      _meta: { ui: { prefersBorder: true } },
    },
  ],
}));

server.registerTool(
  "render_links_widget",
  {
    title: "Render reading list widget",
    description:
      "Render the reading list widget from saved link data. First call list_links, then pass its links to this tool.",
    inputSchema: {
      links: z.array(z.object({ id: z.string(), url: z.string(), title: z.string(), read: z.boolean() })),
    },
    outputSchema: {
      links: z.array(z.object({ id: z.string(), url: z.string(), title: z.string(), read: z.boolean() })),
    },
    _meta: { ui: { resourceUri: TEMPLATE_URI } },
  },
  async ({ links }) => ({
    structuredContent: { links },
    content: [{ type: "text", text: `Showing ${links.length} saved links.` }],
  })
);

The call flow is: the model calls list_links, receives structuredContent, then calls render_links_widget with that data, and the widget renders once with final, model-checked context. The widget can also call tools directly for local interactions, such as refresh, without remounting. For ChatGPT-only extras, window.openai offers callTool, sendFollowUpMessage, and requestModal, but use the shared MCP Apps bridge whenever it covers the capability. The full helper list is in the Apps SDK reference, and the walkthrough is in the custom UI guide.

Step 6: Test inside ChatGPT, then package and submit

Connect your server to ChatGPT as a custom MCP server while you develop. For a local server, use a tunnel such as Secure MCP Tunnel, but note that a tunnel connects a private server for testing only and does not satisfy public submission requirements. Run the direct, indirect, edge-case, and out-of-scope prompts from your Step 1 inventory and watch for the failure that matters most: the model calling the wrong tool over a vague description.

For production, deploy at a stable public HTTPS endpoint ending in /mcp, with logging for failed initialization and tool calls. Then package the plugin as a ZIP with plugin.json and mcp.json at the root:

{
  "servers": {
    "reading-list": {
      "url": "https://reading-list.example.com/mcp",
      "transport": "streamable-http"
    }
  }
}

Submission runs through the Plugins page on the OpenAI Platform. The steps, from the official submission guide, are: complete individual or business verification in organization settings, open Plugins and select Upload new or existing plugin, choose your verified developer identity, and upload the ZIP. Automated checks then report metadata and tool findings, which you fix by uploading a corrected ZIP. Connect the MCP server under the MCPs tab, complete domain verification by serving the exact token as plain text at https://<your-host>/.well-known/openai-apps-challenge, and wait for the automated tool scan. Then complete the review information: reviewer credentials on a dedicated test account with sample data, five positive test cases, three negative test cases, a video walkthrough, and release notes. Submit for review, track progress under Review status, and once approved, choose when to publish. Feedback arrives by email, and a rejection can be appealed by replying to that email. We could not verify review turnaround in the lab; OpenAI publishes no timeline, so do not plan a launch date around it.

Copy-paste template: pre-submission checklist

Copy this into your repo as SUBMISSION-CHECKLIST.md and check every box before you upload the ZIP.

# ChatGPT plugin pre-submission checklist

## Server
- [ ] MCP server live at a stable public HTTPS URL ending in /mcp
- [ ] Streamable HTTP transport responds; Inspector connects cleanly
- [ ] Every tool called with valid and invalid inputs; errors are readable
- [ ] Authorization enforced server-side on every private or write tool
- [ ] No secrets, tokens, or personal data in tool results or metadata
- [ ] Logs record failed initializations and tool calls without credentials

## Tools
- [ ] Each tool has one job, a title, and a description that says when to use it
- [ ] outputSchema declared for every tool returning structuredContent
- [ ] readOnlyHint / destructiveHint / openWorldHint match actual behavior
- [ ] Tools work without the widget; widget is a render-only layer

## Package
- [ ] plugin.json and mcp.json at ZIP root; no .env or secrets inside
- [ ] Verified individual or business identity selected
- [ ] Domain verification token served at /.well-known/openai-apps-challenge
- [ ] Reviewer test account works immediately, no MFA, sample data only
- [ ] 5 positive test cases, each run against the test account
- [ ] 3 negative test cases showing refusal or safe fallback
- [ ] Video walkthrough recorded and its URL is accessible
- [ ] Release notes written for this exact package version

When not to use this / Limits

Do not build a ChatGPT app when ChatGPT already does the job natively, when your value is a static document the model can already read, or when your tool needs API-key authentication: the portal currently provides no API-key connection flow and rejects nonempty server headers as a workaround. Do not build it when you cannot host a stable public HTTPS endpoint, since tunnels and local endpoints are for testing only and fail submission. Desktop-only execution has a separate submission path, and projects with EU data residency cannot submit plugins that include an MCP server. For a private team workflow rather than a public listing, skip the directory and connect the MCP server directly in ChatGPT or Codex. Our guide to publishing an MCP server to the registry covers the open registry route that does not depend on OpenAI review.

Cost / scale math

The arithmetic is friendly. OpenAI charges no fee to submit a plugin, so the fixed cost is hosting: a small VPS at about $5 a month, or a serverless free tier, covers a reading-list app serving a few hundred users, since the MCP server only answers when a user invokes a tool. If your tools call a paid third-party API, that is the real variable cost: at 1,000 tool calls a day and $0.001 per call, the API bill is $1 a day, or about $30 a month. The model calls run on the user’s ChatGPT plan, so your bill does not grow with ChatGPT usage. The scaling risk is latency, not price: a cold-start serverless function that takes ten seconds to answer feels broken inside a chat. Measure p95 response time for your slowest tool before submitting, and keep it under a few seconds.

Troubleshooting

SymptomLikely causeCommand to checkFix
Inspector shows “connection refused” on localhost:3000/mcpServer not running, or wrong portcurl -s http://localhost:3000/mcpStart the server with npx tsx src/server.ts
Tool list is empty in ChatGPTTools registered after server.connectCheck registration order in src/server.tsRegister all tools before creating the transport
Widget renders blankRender tool never called, or resource URI mismatchCompare TEMPLATE_URI with _meta.ui.resourceUriMake the strings identical and confirm the model calls list_links first
“Tool call failed” on write tools onlyMissing authorization checkCall the tool in Inspector with and without credentialsEnforce authorization in the handler
Submission blocked at MCP setupDomain verification token missing or wrongcurl https://<your-host>/.well-known/openai-apps-challengeServe the exact token as plain text, no JSON, on the MCP hostname
Automated check flags a tool annotationAnnotation contradicts behavior, for example a write tool marked read-onlyRe-read the tool handlerSet readOnlyHint true only when the tool cannot change state

Common mistakes

  • Attaching the widget to every tool. The iframe re-renders on each call. Keep data tools widget-free and give the template to one render tool only.
  • Writing tool descriptions for humans. The model reads them to choose tools, so “does stuff with links” produces wrong calls. Write the trigger condition: when the user wants to review saved links.
  • Treating _meta as a security boundary. Fields in _meta are hidden from the model, but they are not authorization. Every private or write tool must check credentials in its handler.

Test before you rely on it

  • Run all five positive test cases end to end in ChatGPT with the reviewer test account.
  • Run all three negative test cases and confirm the plugin refuses or clarifies.
  • Call every tool with empty, oversized, and mistyped inputs and confirm readable errors.
  • Disconnect the widget and confirm the tools still complete the workflow by text.
  • Check production logs after a real session and confirm no tokens or personal data were logged.

FAQ

Do I need the Apps SDK, or is an MCP server enough?

An MCP server is the core of the app; the Apps SDK is the toolkit and docs around it, the MCP server plus the optional UI layer and submission flow. If your app needs no custom UI, a plain MCP server with well-described tools is a complete ChatGPT app.

How much does it cost to build and submit a ChatGPT app?

OpenAI charges no fee to submit a plugin. Your costs are hosting, a five-dollar-a-month VPS or a serverless free tier, plus any third-party APIs your tools call. Model calls inside ChatGPT run on the user’s ChatGPT plan, not on your API bill.

Can I test my app in ChatGPT before submitting it?

Yes. Connect your server to ChatGPT as a custom MCP server while you develop, using a tunnel such as Secure MCP Tunnel or ngrok for a local server. Tunnels are for testing only: public submission needs a stable, public HTTPS endpoint.

Why did OpenAI rename ChatGPT apps to plugins?

At DevDay on 29 September 2026, OpenAI folded apps into a shared plugin system for ChatGPT and Codex, adding sidebar experiences, interactive panels, and a redesigned submission flow. Underneath, a plugin is still an MCP server with optional UI, and the Apps SDK docs now live under Plugins.

What gets a plugin rejected in review?

Common reasons are a plugin that duplicates a built-in ChatGPT feature, tools with vague descriptions or wrong safety annotations, a demo plugin with no real function, missing reviewer credentials for a sign-in flow, and test cases the reviewer cannot reproduce. OpenAI’s plugin guidelines list the full rules, and rejections can be appealed by replying to the rejection email.

Can I update my app after it is published?

Yes, and the two kinds of change behave differently. Changes to your hosted MCP server tools are picked up automatically and go live once they pass automated checks, with no new package needed. Changes to metadata, skills, or assets require a new ZIP and another review.

Conclusion

A ChatGPT app is a small, honest contract: one job, a few well-described tools, a widget only where the eye beats the text, and a server that enforces its own authorization. Get the contract right and the directory gives you distribution to 800 million weekly users for the price of a five-dollar server. Before you upload the ZIP, answer this: if a reviewer runs your five positive test cases with your test account tomorrow, does every one pass on the first try? If not, fix the app first, because review is the slowest loop in this process. Next, if you want the app to trigger real workflows, read the complete n8n guide and wire your tools into automations.

Frequently asked questions

Do I need the Apps SDK, or is an MCP server enough?

An MCP server is the core of the app; the Apps SDK is the toolkit and docs around it, the MCP server plus the optional UI layer and submission flow. If your app needs no custom UI, a plain MCP server with well-described tools is a complete ChatGPT app.

How much does it cost to build and submit a ChatGPT app?

OpenAI charges no fee to submit a plugin. Your costs are hosting, a five-dollar-a-month VPS or a serverless free tier, plus any third-party APIs your tools call. Model calls inside ChatGPT run on the user's ChatGPT plan, not on your API bill.

Can I test my app in ChatGPT before submitting it?

Yes. Connect your server to ChatGPT as a custom MCP server while you develop, using a tunnel such as Secure MCP Tunnel or ngrok for a local server. Tunnels are for testing only: public submission needs a stable, public HTTPS endpoint.

Why did OpenAI rename ChatGPT apps to plugins?

At DevDay on 29 September 2026, OpenAI folded apps into a shared plugin system for ChatGPT and Codex, adding sidebar experiences, interactive panels, and a redesigned submission flow. Underneath, a plugin is still an MCP server with optional UI, and the Apps SDK docs now live under Plugins.

What gets a plugin rejected in review?

Common reasons are a plugin that duplicates a built-in ChatGPT feature, tools with vague descriptions or wrong safety annotations, a demo plugin with no real function, missing reviewer credentials for a sign-in flow, and test cases the reviewer cannot reproduce. OpenAI's plugin guidelines list the full rules, and rejections can be appealed by replying to the rejection email.

Can I update my app after it is published?

Yes, and the two kinds of change behave differently. Changes to your hosted MCP server tools are picked up automatically and go live once they pass automated checks, with no new package needed. Changes to metadata, skills, or assets require a new ZIP and another review.

TP
ToolPilot Team

We install, break, and fix automation tools so you don't have to. Every tutorial on ToolPilot is tested hands-on before publishing.

Keep reading