All posts

ChatGPT App Tutorial: Build Your First App in 5 Minutes (April 2026)

Abe Wheeler
ChatGPT Apps MCP Apps Tutorial Getting Started ChatGPT App Framework MCP App Framework ChatGPT App Testing MCP App Testing
ChatGPT App for cooking recipes.

ChatGPT App for cooking recipes.

TL;DR: Scaffold a project with npx sunpeak new, create a React component, add a simulation file with mock data, and run the inspector. You will have a working ChatGPT App on localhost in 5 minutes, no paid account required.

A ChatGPT App is a web application that renders inside ChatGPT’s UI. When ChatGPT calls a tool, instead of returning the result as plain text, your app renders it as interactive UI: cards, forms, charts, whatever you build with React. Under the hood, ChatGPT Apps use the MCP Apps open standard, so the same app also runs on Claude, Goose, VS Code, and other MCP-compatible hosts.

ChatGPT can recommend recipes, but it can only describe them as text. With a ChatGPT App, that same recipe becomes a structured card with prep times, a clickable ingredient list, and numbered steps. The difference between a wall of text and something people actually want to use.

In this tutorial, you will build that recipe card from scratch. By the end, you will have a working app running in a local ChatGPT inspector that you can extend, test, and ship to production.

Prerequisites

You need Node.js 20 or later and pnpm (npm or yarn work too, but this tutorial uses pnpm). You do not need a ChatGPT account. sunpeak’s local inspector handles everything during development.

Step 1: Create a New Project

Scaffold a project:

npx sunpeak new

Follow the prompts to configure your project. npx sunpeak new creates a complete project structure with dependencies installed, TypeScript config, and a sample resource to get you started. It also sets up the testing framework so you can write unit tests and e2e tests from the start.

cd into your new project directory before continuing. You should see a src/resources/ directory (where your app code lives) and a tests/simulations/ directory (where mock data lives).

Step 2: Write Your First Resource

A resource is a React component that renders tool data from ChatGPT. Each resource has two parts: a config object that describes it and a component that renders it.

sunpeak auto-discovers resources by directory convention. Any file at src/resources/{name}/{name}.tsx becomes a resource. Create the file src/resources/recipe/recipe.tsx:

import { useToolData, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';

export const resource: ResourceConfig = {
  title: 'Recipe',
  description: 'Display a recipe with ingredients and steps',
};

interface RecipeData {
  title: string;
  prepTime: string;
  cookTime: string;
  servings: number;
  ingredients: string[];
  steps: string[];
  tags: string[];
}

export function RecipeResource() {
  const { output, isLoading, isError } = useToolData<unknown, RecipeData>(undefined, undefined);

  if (isLoading) return <div className="p-6 text-gray-400">Loading recipe...</div>;
  if (isError) return <div className="p-6 text-red-500">Failed to load recipe.</div>;
  if (!output) return null;

  return (
    <SafeArea className="p-6 font-sans max-w-lg mx-auto">
      <h1 className="text-2xl font-bold mb-1">{output.title}</h1>

      <div className="flex gap-2 mb-4 mt-2">
        {output.tags.map((tag) => (
          <span key={tag} className="px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs font-medium">
            {tag}
          </span>
        ))}
      </div>

      <div className="flex gap-3 text-sm mb-6">
        <div className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-3 py-2">
          <span className="text-gray-400">🕐</span>
          <div>
            <div className="text-gray-400 text-xs">Prep</div>
            <div className="font-medium">{output.prepTime}</div>
          </div>
        </div>
        <div className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-3 py-2">
          <span className="text-gray-400">🔥</span>
          <div>
            <div className="text-gray-400 text-xs">Cook</div>
            <div className="font-medium">{output.cookTime}</div>
          </div>
        </div>
        <div className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-3 py-2">
          <span className="text-gray-400">🍽️</span>
          <div>
            <div className="text-gray-400 text-xs">Serves</div>
            <div className="font-medium">{output.servings}</div>
          </div>
        </div>
      </div>

      <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
        <div>
          <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-400 mb-2">Ingredients</h2>
          <ul className="space-y-1.5">
            {output.ingredients.map((item, i) => (
              <li key={i} className="flex items-start gap-2">
                <span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-amber-400 shrink-0" />
                {item}
              </li>
            ))}
          </ul>
        </div>

        <div>
          <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-400 mb-2">Steps</h2>
          <ol className="space-y-3">
            {output.steps.map((step, i) => (
              <li key={i} className="flex gap-3">
                <span className="flex items-center justify-center h-6 w-6 rounded-full bg-amber-400 text-white text-xs font-bold shrink-0">
                  {i + 1}
                </span>
                <span className="pt-0.5">{step}</span>
              </li>
            ))}
          </ol>
        </div>
      </div>
    </SafeArea>
  );
}

Here’s what each piece does:

  • resource tells ChatGPT the name and purpose of your app. sunpeak uses this to register the resource with the MCP server automatically.
  • useToolData is a React hook that gives your component the data ChatGPT sent. The generic types <unknown, RecipeData> mean we don’t care about the tool input, but we expect the output to match our RecipeData shape. It also returns isLoading, isError, and isCancelled for handling different states.
  • SafeArea wraps your content with proper insets so it renders correctly across display modes (inline, picture-in-picture, and fullscreen).
  • The loading and error checks handle the brief moment before data arrives and any failures.

The rest is standard React with Tailwind classes. Nothing framework-specific about the UI itself, which means the same component works on Claude, Goose, VS Code, and other hosts without changes.

Step 3: Write the Tool

A resource renders the UI. A Tool triggers it. When ChatGPT decides to call your tool, the tool handler runs, returns structured data, and ChatGPT renders your resource with that data.

sunpeak auto-discovers tools from src/tools/*.ts. Create src/tools/show-recipe.ts:

import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  resource: 'recipe',
  title: 'Show Recipe',
  description: 'Look up a recipe and display it',
  annotations: { readOnlyHint: true },
};

export const schema = {
  query: z.string().describe('Recipe name or search term'),
};

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

export default async function (args: Args, _extra: ToolHandlerExtra) {
  // In production, fetch from your recipe API using args.query
  return {
    structuredContent: {
      title: 'Classic Chocolate Chip Cookies',
      prepTime: '15 min',
      cookTime: '12 min',
      servings: 24,
      ingredients: ['2¼ cups flour', '1 cup butter', '2 cups chocolate chips'],
      steps: ['Preheat oven to 375°F.', 'Mix dry ingredients.', 'Bake 9-11 minutes.'],
      tags: ['dessert', 'baking', 'cookies'],
    },
  };
}

Three exports, each with a specific job:

  • tool links this tool to the recipe resource and describes it to ChatGPT. The resource field connects it to the component you created in Step 2. The annotations.readOnlyHint tells the model this tool only reads data (no side effects).
  • schema defines the tool’s input parameters using Zod. ChatGPT uses the descriptions to decide what arguments to pass. sunpeak converts this to the JSON Schema format that MCP requires.
  • The default export is the handler that runs when ChatGPT calls the tool. It returns structuredContent matching the shape your resource expects. In production, you would replace the hardcoded data with an API call or database query.

When ChatGPT calls show-recipe, the MCP server runs this handler and sends the structuredContent to your RecipeResource component via useToolData.

Step 4: Add Test Data

The inspector needs mock data to render your resource. This is a simulation file: a JSON file that describes what the user said, what tool ChatGPT called, and what data came back.

Simulations reference tools by name. Create tests/simulations/recipe-cookies.json:

{
  "tool": "show-recipe",
  "userMessage": "Give me a recipe for chocolate chip cookies",
  "toolInput": {
    "query": "chocolate chip cookies"
  },
  "toolResult": {
    "structuredContent": {
      "title": "Classic Chocolate Chip Cookies",
      "prepTime": "15 min",
      "cookTime": "12 min",
      "servings": 24,
      "ingredients": [
        "2¼ cups all-purpose flour",
        "1 tsp baking soda",
        "1 tsp salt",
        "1 cup butter, softened",
        "¾ cup sugar",
        "¾ cup brown sugar",
        "2 large eggs",
        "2 tsp vanilla extract",
        "2 cups chocolate chips"
      ],
      "steps": [
        "Preheat oven to 375°F.",
        "Mix flour, baking soda, and salt in a bowl.",
        "Beat butter, sugar, and brown sugar until creamy.",
        "Add eggs and vanilla to the butter mixture.",
        "Gradually blend in the flour mixture.",
        "Stir in chocolate chips.",
        "Drop rounded tablespoons onto ungreased baking sheets.",
        "Bake 9 to 11 minutes or until golden brown."
      ],
      "tags": ["dessert", "baking", "cookies"]
    }
  }
}

The key field is toolResult.structuredContent. That object is exactly what useToolData returns as output in your component.

You can create multiple simulation files for the same tool to test different scenarios. For example, a recipe-salad.json with a different recipe lets you switch between test cases in the inspector. This is also how sunpeak’s testing framework works: your unit tests and e2e tests load simulation files to verify your component renders correctly for each case.

Step 5: Run the Inspector

Start the development server:

pnpm dev

This launches two servers: the inspector at http://localhost:3000 and the MCP server at http://localhost:8000. Both support Hot Module Reload, so changes to your components and tool handlers reflect instantly.

Open http://localhost:3000. You should see the ChatGPT inspector with your recipe card rendered inside a chat conversation. The user message “Give me a recipe for chocolate chip cookies” appears in the chat, and your component renders the structured recipe below it.

From here, try switching display modes in the inspector toolbar. Your app can render inline (inside the chat), fullscreen, or picture-in-picture. Toggle dark mode to check that your Tailwind classes handle both themes. And edit your component while the inspector is running: changes hot reload instantly.

How It Works

The data flow in a ChatGPT App follows the MCP Apps standard:

  1. The user asks ChatGPT something (“Give me a recipe for cookies”).
  2. ChatGPT decides to call a tool (show-recipe). Your MCP server executes the tool handler and returns structured data.
  3. ChatGPT passes that data to your resource component via structuredContent. The resource runs in a sandboxed iframe inside the ChatGPT UI.
  4. Your component receives it through useToolData and renders it as UI.

Your resource is pure UI. It doesn’t call APIs or run server logic. ChatGPT handles the intelligence, and your app handles the presentation. This separation is why MCP Apps are portable across hosts: the same component works in ChatGPT, Claude, Goose, VS Code, Postman, and MCPJam from a single codebase. The MCP Apps introduction covers this architecture in detail.

During development, the simulation file stands in for ChatGPT. In production, real tool calls from ChatGPT replace the mock data, and your component renders identically because the data shape is the same.

Make It Your Own

A few ideas to try now that you have a working app.

Add state with useAppState

Let the user check off ingredients as they cook. useAppState works like useState but syncs state back to ChatGPT, so the model knows what the user has done.

import { useToolData, useAppState, SafeArea } from 'sunpeak';

// Inside your component:
const [checked, setChecked] = useAppState<Record<number, boolean>>({});

// Then in your ingredients list:
{output.ingredients.map((item, i) => (
  <li key={i}
    className={`flex items-start gap-2 cursor-pointer ${checked[i] ? 'line-through text-gray-400' : ''}`}
    onClick={() => setChecked({ ...checked, [i]: !checked[i] })}
  >
    <span className={`mt-1.5 h-1.5 w-1.5 rounded-full shrink-0 ${checked[i] ? 'bg-gray-300' : 'bg-amber-400'}`} />
    {item}
  </li>
))}

Respond to display mode

Use useDisplayMode to adjust your layout when the user switches from inline to fullscreen. The display mode reference covers every mode and its CSS behavior.

Add host-aware styling

Your app inherits CSS variables from the host for theme-aware styling. Use useTheme to read whether the user is in light or dark mode and adjust colors accordingly.

Write tests

sunpeak includes a full testing framework. Write unit tests with Vitest to verify your component renders correctly for each simulation. Add snapshot tests to catch unintended UI changes. Run pnpm test:unit to execute them locally, or set up CI/CD to run them on every push.

Add another resource

Create src/resources/meal-plan/meal-plan.tsx with a weekly meal planner UI. Each resource is independent, and sunpeak auto-discovers new ones from the directory structure.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

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

No. sunpeak includes a local ChatGPT inspector that replicates the full ChatGPT App runtime. You can build, test, and iterate entirely on localhost without any OpenAI account. The inspector supports all display modes, themes, and screen sizes. You only need a ChatGPT account when you are ready to deploy and submit your finished app.

What is the fastest way to start building a ChatGPT App in 2026?

Run "npx sunpeak new" to scaffold a project, then cd into the directory and run "pnpm dev" to launch the local inspector at localhost:3000 and the MCP server at localhost:8000. You can have a working ChatGPT App running in a couple of minutes. The scaffolded project includes a sample resource, tool, simulation file, and test setup.

What is a resource in a ChatGPT App?

A resource is a React component paired with a configuration object that tells ChatGPT what your app does. It renders tool data (the structured output from a ChatGPT tool call) as interactive UI inside the chat. Each resource lives in its own directory under src/resources/ and is auto-discovered by sunpeak. Resources use the MCP Apps ui:// URI scheme under the hood, which means the same resource works on ChatGPT, Claude, and other MCP-compatible hosts.

What is a simulation file and why do I need one?

A simulation file is a JSON file in tests/simulations/ that contains mock tool data for local development. It defines what the user said, what tool ChatGPT called, and what data the tool returned. The sunpeak inspector loads these files so you can develop and test your UI across all states without connecting to a real ChatGPT session. Multiple simulation files for the same tool let you test different scenarios like loading states, error states, and edge cases.

How does useToolData work in a ChatGPT App?

useToolData is a React hook from sunpeak that gives your component access to the data ChatGPT sends when it calls a tool. You call it with optional type parameters for the tool input and output shapes. It returns an object with output (the tool result data), isLoading, isError, isCancelled, and other status fields. Your component renders the output as UI. The same hook works identically across ChatGPT, Claude, and other MCP App hosts.

Can I use Tailwind CSS in a ChatGPT App?

Yes. sunpeak projects include Tailwind CSS by default. You can use Tailwind utility classes in your resource components. ChatGPT Apps also have access to CSS variables from the host for theme-aware styling, such as --color-background-primary, --color-text-primary, --font-sans, and --border-radius-md. sunpeak provides a useTheme hook that returns "light" or "dark" so you can conditionally style elements.

How do I test a ChatGPT App locally?

Run "pnpm dev" to start the local inspector at localhost:3000. The inspector loads your simulation files and renders your resources exactly as ChatGPT would. You can test different display modes (inline, fullscreen, picture-in-picture), themes (light, dark), and screen sizes. For automated testing, sunpeak includes a full testing framework: "pnpm test:unit" for unit tests, "pnpm test" for e2e tests, and "pnpm test:visual" for visual regression tests, all running without a ChatGPT account.

What is the difference between a ChatGPT App and an MCP App?

A ChatGPT App is an MCP App that runs inside ChatGPT. MCP Apps is the open standard (ext-apps, now at v1.7.0) that defines how interactive UI apps communicate with AI hosts. When you build with sunpeak, you build an MCP App that runs on ChatGPT, Claude, Goose, VS Code (via Copilot), Postman, and MCPJam from a single codebase. You can add ChatGPT-specific features through sunpeak/chatgpt imports without affecting portability.

How do I deploy a ChatGPT App to production?

Run "pnpm build && pnpm start" to compile and launch the production MCP server. Use a tunnel (like ngrok) or deploy to a hosting provider to get a public URL, then submit that URL as a ChatGPT App in the OpenAI developer portal. sunpeak compiles your tool handlers, Zod schemas, and resource bundles into an optimized production build. Your app serves the same UI that worked locally in the inspector.