All posts

How to Build a ChatGPT App: A Complete Technical Plan (July 2026)

Abe Wheeler
ChatGPT AppsMCP AppsMCP App FrameworkChatGPT App FrameworkChatGPT App TestingMCP App TestingGetting Started
How it feels to develop ChatGPT Apps (good).

How it feels to develop ChatGPT Apps (good).

Here is the plan I would follow to build a production ChatGPT App from a blank repo in July 2026. It assumes you want the app to survive real users, real host behavior, and future host changes.

TL;DR: Build the app as an MCP App first. Define tools, resources, structuredContent, outputSchema, _meta.ui.resourceUri, and the host bridge contract before you polish UI. Use sunpeak if you want scaffolding, a local ChatGPT and Claude inspector, simulation files, Playwright tests, visual regression tests, live host tests, and evals. Connect to live ChatGPT after local tests pass, then deploy the MCP server and submit with review-ready metadata.

What Changed Since April

The biggest change is that ChatGPT App architecture is now clearer and more portable. OpenAI’s current Apps SDK docs point developers toward the MCP Apps standard: use _meta.ui.resourceUri to link tools to UI resources, use the ui/* bridge for host communication, and keep ChatGPT-only APIs behind optional feature checks.

The MCP Apps package has also kept moving. The @modelcontextprotocol/ext-apps package is at 1.7.4 as of this refresh, and the official MCP Apps docs describe the same app shape from the protocol side: MCP tools plus sandboxed UI resources that communicate over JSON-RPC messages.

The practical mental model is now simple: build an MCP App that runs in ChatGPT.

The Architecture

A ChatGPT App has four layers:

  1. MCP server - exposes tools, resources, auth, and server metadata.
  2. Tools - model-callable actions with names, descriptions, schemas, annotations, and optional UI links.
  3. Resources - HTML UI bundles rendered in sandboxed iframes.
  4. Host bridge - the message channel between ChatGPT and the resource iframe.

When the user asks for something, the flow looks like this:

  1. ChatGPT decides a tool is relevant.
  2. ChatGPT calls your MCP server with structured tool arguments.
  3. Your tool returns content, structuredContent, and optional _meta.
  4. ChatGPT loads the UI resource linked by _meta.ui.resourceUri.
  5. The resource receives tool input, tool output, host context, display mode, theme, safe area, and app state through the bridge.
  6. User interactions can call app-visible server tools, send follow-up messages, or update model-visible app state.

That split matters because each layer can break independently. A React component can be correct while the resource URI is wrong. A tool can return good data but omit outputSchema. A fullscreen layout can work in the inspector and fail in picture-in-picture. The technical plan needs to test all of those boundaries.

Choose the Starting Point

For a new app, scaffold the project:

npx sunpeak new chatgpt-app
cd chatgpt-app
pnpm dev

This gives you a TypeScript project with a local MCP server, React resources, tool files, simulation fixtures, and an inspector at localhost:3000.

For an existing MCP server, keep the server where it is and inspect it:

npx sunpeak inspect --server http://localhost:8000/mcp

That path is useful when your server is already written in Python, Go, Rust, TypeScript, or another stack. You can test the MCP tool and UI contract without moving the backend into a new framework.

Define the Tool Contract First

Start with the server contract because the UI depends on it.

A strong ChatGPT App tool descriptor has:

  • A literal tool name.
  • A description that says when the model should use the tool.
  • inputSchema for model-supplied arguments.
  • outputSchema when the tool returns structuredContent.
  • annotations such as readOnlyHint for model planning and review.
  • _meta.ui.resourceUri or a framework-level resource link.
  • Clear visibility for model-callable vs app-only tools.

In a sunpeak project, a tool file can stay compact while still compiling to the MCP Apps shape:

// src/tools/search-docs.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  resource: 'docs-search',
  title: 'Search Docs',
  description: 'Search product documentation and show matching pages.',
  annotations: { readOnlyHint: true },
  outputSchema: z.object({
    query: z.string(),
    results: z.array(
      z.object({
        id: z.string(),
        title: z.string(),
        url: z.string().url(),
        snippet: z.string(),
      }),
    ),
  }),
};

export const schema = {
  query: z.string().describe('The documentation search query.'),
};

type Args = z.infer<z.ZodObject<typeof schema>>;

export default async function (args: Args, _extra: ToolHandlerExtra) {
  const page = await searchDocs(args.query);

  return {
    content: [{ type: 'text' as const, text: `Found ${page.results.length} docs.` }],
    structuredContent: {
      query: args.query,
      results: page.results.map((result) => ({
        id: result.id,
        title: result.title,
        url: result.url,
        snippet: result.snippet,
      })),
    },
    _meta: {
      nextCursor: page.nextCursor,
    },
  };
}

The important part is the data split:

  • content is short text the model can read.
  • structuredContent is the model-visible JSON object your app renders.
  • _meta is for UI-only helper data when the host supports it.
  • outputSchema describes the shape of structuredContent.

Do not put private tokens, hidden IDs, large raw payloads, or UI-only cursors in structuredContent unless you are comfortable with the model seeing them. For more detail, read the guide to tool results, structuredContent, and _meta.

Build the Resource Around Data States

A resource is the UI ChatGPT renders. In sunpeak, it is a React component plus a resource config:

// src/resources/docs-search/docs-search.tsx
import { SafeArea, useDisplayMode, useToolData } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';

interface SearchOutput {
  query: string;
  results: Array<{
    id: string;
    title: string;
    url: string;
    snippet: string;
  }>;
}

export const resource: ResourceConfig = {
  title: 'Documentation Search',
  description: 'Show documentation search results.',
};

export default function DocsSearchResource() {
  const { output, isLoading, isError, isCancelled } = useToolData<unknown, SearchOutput>(
    undefined,
    undefined,
  );
  const { displayMode } = useDisplayMode();

  if (isLoading) return <SafeArea className="p-4">Searching docs...</SafeArea>;
  if (isCancelled) return <SafeArea className="p-4">Search cancelled.</SafeArea>;
  if (isError) return <SafeArea className="p-4">Search failed.</SafeArea>;
  if (!output) return null;

  return (
    <SafeArea className="p-4 font-sans">
      <header className="mb-4">
        <p className="text-sm text-gray-500">Search results for</p>
        <h1 className="text-xl font-semibold">{output.query}</h1>
      </header>

      <div className={displayMode === 'fullscreen' ? 'grid gap-3 md:grid-cols-2' : 'space-y-3'}>
        {output.results.map((result) => (
          <article key={result.id} className="rounded-lg border p-3">
            <h2 className="font-medium">{result.title}</h2>
            <p className="text-sm text-gray-600">{result.snippet}</p>
          </article>
        ))}
      </div>
    </SafeArea>
  );
}

Build the resource around states, not just the happy path:

  • Loading before the first tool result arrives.
  • Empty results.
  • Server errors.
  • User-cancelled or approval-gated flows.
  • Long lists, long labels, and missing optional fields.
  • Inline, fullscreen, and picture-in-picture display modes.
  • Light and dark themes.
  • Mobile and desktop widths.

These states are where ChatGPT App bugs usually hide. The app runs in a host-owned iframe, so layout and data timing matter more than in a normal browser tab.

Make Interactivity Explicit

Read-only resources are easy: the tool returns data and the resource renders it.

Interactive apps need one more design pass. Decide which interactions should be visible to the model, which should call server tools, and which should stay local to the UI.

Use these buckets:

InteractionBest pathExample
Local UI stateComponent state or useAppStateSorting a table, selected tab, draft form fields
Model-visible stateuseAppState or model context updateUser selected invoice INV-123
Server action from UIApp-visible MCP toolLoad next page, validate a field, save a draft
Conversation actionui/message or host-specific equivalentAsk ChatGPT to explain the selected result

App-only tools are especially useful. A “load more” tool should usually be callable by the rendered app, not by the model. Keep the model-facing tool list small and literal, then expose UI-driven helper tools through app visibility.

For a deeper look at this pattern, read MCP App actions: callServerTool, sendMessage, and update model context.

Test the Contract Before the UI

Before debugging React, prove the MCP boundary works.

Add contract tests that check:

  • tools/list includes the expected tool.
  • Tool names, descriptions, annotations, and schemas are present.
  • UI-backed tools point at a valid resource.
  • Resource reads return the expected HTML MIME type.
  • Resource metadata includes the CSP and permissions your UI needs.
  • Tool calls return valid content, structuredContent, and _meta.
  • structuredContent matches outputSchema.
  • Write tools are marked and confirmed correctly.

With sunpeak tests, a minimal contract test looks like this:

import { expect, test } from 'sunpeak/test';

test('search_docs exposes a UI resource and valid structured output', async ({ mcp }) => {
  const tools = await mcp.listTools();
  const tool = tools.tools.find((item) => item.name === 'search_docs');

  expect(tool?.title).toBe('Search Docs');
  expect(tool?._meta?.ui?.resourceUri).toEqual(expect.stringMatching(/^ui:\/\//));

  const result = await mcp.callTool('search_docs', { query: 'oauth' });
  expect(result.isError).toBeFalsy();
  await expect(result).toHaveStructuredContent({
    query: 'oauth',
    results: expect.any(Array),
  });
});

This catches the failures that otherwise show up as a blank iframe.

Test the Rendered App Locally

After the server contract works, test the rendered resource in a host replica.

Create simulation files for every meaningful UI state:

{
  "tool": "search_docs",
  "userMessage": "Find docs about OAuth setup",
  "toolInput": {
    "query": "oauth setup"
  },
  "toolResult": {
    "content": [{ "type": "text", "text": "Found 2 docs." }],
    "structuredContent": {
      "query": "oauth setup",
      "results": [
        {
          "id": "auth-quickstart",
          "title": "OAuth quickstart",
          "url": "https://example.com/docs/oauth",
          "snippet": "Set up OAuth 2.1 with PKCE."
        },
        {
          "id": "connector-auth",
          "title": "Connector authentication",
          "url": "https://example.com/docs/connectors/auth",
          "snippet": "Configure remote MCP auth for hosted apps."
        }
      ]
    }
  }
}

Then write Playwright tests against the inspector:

import { expect, test } from 'sunpeak/test';

test('docs search renders in fullscreen dark mode', async ({ inspector }) => {
  const result = await inspector.renderTool('search_docs', undefined, {
    displayMode: 'fullscreen',
    theme: 'dark',
  });

  const app = result.app();
  await expect(app.getByText('OAuth quickstart')).toBeVisible();
  await expect(app.getByText('Connector authentication')).toBeVisible();
});

Run the suite locally and in CI:

pnpm test

For UI-heavy apps, add visual regression tests. For apps where tool selection matters, add evals that check whether models call the right tool with the right arguments. For release readiness, add live tests against ChatGPT or Claude on a slower path.

Plan the Local Development Loop

The loop should be:

  1. Edit resource or tool code.
  2. Run the local MCP server and inspector.
  3. Switch simulation states in the inspector.
  4. Run pnpm test.
  5. Run a small live host check only after local coverage passes.

Avoid using live ChatGPT as your main development loop. It is slower, harder to automate, and more expensive to repeat because every check depends on connector state, model behavior, host cache, network timing, and manual prompts.

sunpeak’s value here is practical: it replicates ChatGPT and Claude style runtimes locally, gives each state a URL, and reuses the same runtime in Playwright. That keeps most feedback in localhost and CI.

Connect to Live ChatGPT

When local tests pass, connect the app to ChatGPT.

Expose your MCP server over HTTPS:

ngrok http 8000

Developer mode is required to add a custom app. Enable it from the user component in the bottom-left corner under Settings > Security and login > Developer mode. Then open Plugins from the ChatGPT sidebar, select an existing development app, or use the + button to add one with your MCP connection and listing details.

For live validation, check:

  • The connector registers without metadata errors.
  • Tool descriptions make ChatGPT call the right tool.
  • The resource iframe loads on first use.
  • Display modes behave as expected.
  • Auth, consent, and approval flows match the action.
  • The app works in a fresh chat.
  • Mobile behavior matches the OpenAI testing checklist.
  • A metadata refresh pulls the newest resource bundle when you change server metadata.

Keep this live pass small. The local tests should already cover data states, visual states, and basic interactions.

Deploy

A production ChatGPT App needs two deployed pieces:

  • A public HTTPS MCP server.
  • Production resource bundles served by that server.

Run:

pnpm build

Then deploy to your Node host, serverless platform, container, or existing backend. The important requirements are not specific to one host:

  • Stable HTTPS endpoint.
  • Correct MCP transport.
  • Strict resource CSP and permissions.
  • Auth that works in a host-initiated flow.
  • Logs that separate tool calls, resource reads, and app-originated tool calls.
  • Versioned resource output or a clear metadata refresh path.

For more detail, read how to deploy an MCP App and MCP App observability for production debugging.

Submit the App as a Plugin

OpenAI now nests Apps inside Plugins for development and public distribution. A plugin can contain an MCP-backed app, skills, or both. This does not change the app architecture. Your MCP App or Apps SDK server, UI resources, tool metadata, and host bridge stay the same.

In the OpenAI Platform plugin portal, choose Create plugin, then With MCP. Before submission, prepare:

  • Plugin name, short and long descriptions, logo, category, and publisher details.
  • Public MCP server URL.
  • Website, support, privacy policy, and terms URLs.
  • Apps Management write access and a verified individual or business identity.
  • Domain verification through the generated /.well-known/openai-apps-challenge URL.
  • Exact CSP domains and accurate readOnlyHint, openWorldHint, and destructiveHint values for every tool.
  • Starter prompts, exactly five positive test cases, and exactly three negative test cases.
  • Review credentials without MFA, SMS, email confirmation, or private-network access.
  • Country or region availability and release notes.

Submit the production MCP server rather than an existing ChatGPT app ID. After approval, you choose when to publish the plugin. App-only, skills-only, and app-plus-skills plugins all appear in the universal plugin directory in ChatGPT and Codex. Use OpenAI’s current plugin submission docs and Apps SDK submission guidelines as the final source of truth.

The Build Order I Would Use

This is the sequence I would put in an issue tracker:

  1. Define the user job and the tool list.
  2. Write tool schemas, annotations, and output schemas.
  3. Design structuredContent for each UI-backed tool.
  4. Add simulation files for happy, empty, error, cancelled, and large-data states.
  5. Build the first resource against simulations.
  6. Add MCP contract tests.
  7. Add inspector E2E tests across display modes and themes.
  8. Add app-only tools for pagination, validation, or follow-up actions.
  9. Add visual tests for important screens.
  10. Connect to live ChatGPT and fix host-specific gaps.
  11. Deploy a production MCP server.
  12. Run a live regression pass.
  13. Submit an app-only plugin with the production MCP server and review-ready metadata.

The order matters. If you start with the live ChatGPT connector, you will spend too much time refreshing and guessing. If you start with the contract and simulations, the UI, tests, and live validation all have a stable base.

Where sunpeak Fits

sunpeak is an open-source MCP App framework, ChatGPT App framework, Claude Connector framework, inspector, and testing framework. It is useful when you want the standard path to be boring:

  • npx sunpeak new for a complete MCP App project.
  • npx sunpeak inspect --server <url> for an existing MCP server.
  • React hooks for tool data, host context, display mode, app state, and actions.
  • Simulation fixtures for deterministic app states.
  • Playwright tests through the inspector.
  • Visual regression tests for layout drift.
  • Live host tests for final ChatGPT and Claude validation.
  • Evals for tool-calling quality.

You can build the app without a framework, especially for a small prototype. The reason to use a framework is not that React is hard. The hard parts are the MCP boundary, host bridge, resource metadata, iframe constraints, test matrix, and release loop.

If you are starting today, start with sunpeak’s quickstart, keep the OpenAI Apps SDK docs open for ChatGPT-specific app behavior, use the plugin submission docs for review requirements, and keep the MCP Apps overview open for the portable contract.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What is the best technical plan for building a ChatGPT App in 2026?

Start with the MCP Apps standard. Define an MCP server, model-callable tools, UI resources, structuredContent, outputSchema, resource metadata, and a local test loop before connecting to live ChatGPT. Use a framework such as sunpeak when you want scaffolding, local inspection, simulation files, Playwright tests, visual regression tests, live host tests, and cross-host support from the first commit.

What is the architecture of a ChatGPT App?

A ChatGPT App is an MCP App running in ChatGPT. Your MCP server exposes tools and UI resources. ChatGPT calls a tool, receives content and structuredContent from the server, loads the tool-linked UI resource in a sandboxed iframe, and exchanges host state, tool input, tool output, app state, and follow-up actions through a postMessage JSON-RPC bridge.

Should new ChatGPT Apps use window.openai or MCP Apps APIs?

Use the MCP Apps standard first: _meta.ui.resourceUri, ui/* bridge messages, tools/call, ui/message, ui/update-model-context, content, structuredContent, and outputSchema. Use window.openai only for ChatGPT-specific extensions when the standard does not cover the feature, and feature-detect it so your app still works in other MCP App hosts.

Do I need a paid ChatGPT account to develop a ChatGPT App?

No for most development. You can build and test the app locally with sunpeak at localhost:3000 and an MCP server at localhost:8000. You only need live ChatGPT access when you are ready to validate developer-mode setup, real host metadata, authentication, submission behavior, and final user flows.

How should I test a ChatGPT App?

Use layered tests. Unit-test pure component and tool logic. Add MCP contract tests for tools/list, inputSchema, outputSchema, structuredContent, _meta.ui.resourceUri, resource reads, CSP, and annotations. Run inspector E2E tests in Playwright across host, theme, display mode, and viewport states. Add visual regression tests for UI-heavy apps, live host tests before release, and evals for tool-selection quality.

What data should a ChatGPT App tool return?

Return short model-readable content, UI-ready structuredContent, and optional _meta for UI-only helper data when the host supports it. Declare outputSchema for structuredContent so the server, host, model, resource, and tests all share the same contract. Keep private tokens, cursors that should not be model-visible, and large raw payloads out of structuredContent.

Can one ChatGPT App also work in Claude and other MCP App hosts?

Yes, if the app is built against the MCP Apps standard instead of one host-specific surface. Core APIs such as tool data, host context, display modes, app state, and resource metadata map to the standard. Host-specific features can still be added behind optional imports or feature checks.

How do I deploy and submit a ChatGPT App?

Deploy a publicly reachable HTTPS MCP server with production resource bundles, strict resource metadata, authentication if needed, a privacy policy, and review-ready plugin metadata. Run automated and live ChatGPT tests first. In the OpenAI Platform plugin portal, choose With MCP and submit the production MCP server with starter prompts, five positive tests, three negative tests, availability, and release notes.