All posts

ChatGPT App Tutorial: Build Your First App in 5 Minutes

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

ChatGPT App for cooking recipes.

TL;DR: Install sunpeak, scaffold a project, create a React component, add a simulation file with mock data, and run the simulator. 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 dumping the result as plain text, your app renders it as real interactive UI: cards, forms, charts, whatever you build with React.

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 simulator 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 simulator handles everything during development.

Step 1: Create a New Project

Install sunpeak and scaffold a project:

pnpm add -g sunpeak
sunpeak new

Follow the prompts to name your project. sunpeak new creates a complete project structure with dependencies installed, TypeScript config, and a sample resource to get you started.

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}-resource.tsx becomes a resource. Create the file src/resources/recipe/recipe-resource.tsx:

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

export const resource: ResourceConfig = {
  name: 'recipe',
  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 } = useToolData<unknown, RecipeData>(undefined, undefined);

  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.
  • 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.
  • SafeArea wraps your content with proper insets so it renders correctly across display modes.
  • The if (!output) return null check handles the brief moment before data arrives.

The rest is standard React with Tailwind classes. Nothing framework-specific about the UI itself.

Step 3: Add Test Data

The simulator 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 are matched to resources by directory name. Since your resource is in src/resources/recipe/, put the simulation in tests/simulations/recipe/. Create tests/simulations/recipe/recipe-cookies-simulation.json:

{
  "userMessage": "Give me a recipe for chocolate chip cookies",
  "tool": {
    "name": "show-recipe",
    "description": "Display a recipe with ingredients and steps",
    "inputSchema": {
      "type": "object",
      "properties": {
        "query": {
          "type": "string",
          "description": "What recipe to look up"
        }
      },
      "required": ["query"]
    },
    "title": "Show Recipe",
    "annotations": { "readOnlyHint": true },
    "_meta": {
      "ui": { "visibility": ["model", "app"] }
    }
  },
  "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 per resource to test different scenarios. For example, a recipe-salad-simulation.json with a different recipe would let you switch between test cases in the simulator.

Step 4: Run the Simulator

Start the development server:

sunpeak dev

Open http://localhost:3000. You should see the ChatGPT simulator 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 simulator toolbar. Your app can render inline (inside the chat), fullscreen, or picture-in-picture. Toggle dark mode to check that your Tailwind classes work in both themes. And edit your component while the simulator is running: changes hot reload instantly.

How It Works

The data flow in a ChatGPT App is:

  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 and returns structured data.
  3. ChatGPT passes that data to your resource component via structuredContent.
  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, and any other MCP-compatible host.

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 another resource

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

What’s Next

You have a working ChatGPT App. Here’s where to go from here:

Ready to build? Run pnpm add -g sunpeak && sunpeak new and start your first ChatGPT App.

Frequently Asked Questions

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

No. Sunpeak includes a local ChatGPT simulator that replicates the full ChatGPT App runtime. You can build, test, and iterate on your app entirely on localhost without any OpenAI account. You only need a ChatGPT account when you are ready to deploy your finished app.

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

Install sunpeak globally with pnpm add -g sunpeak, then run sunpeak new to scaffold a project. Run sunpeak dev to launch the local simulator. You can have a working ChatGPT App running in a couple minutes.

What is a resource in sunpeak?

A resource is an MCP concept for a piece of data, but more specifically in the sunpeak context it is a React component paired with a configuration object that tells ChatGPT what your app does. The component receives tool data (the structured output from a ChatGPT tool call) and renders it as interactive UI. Each resource lives in its own directory under src/resources/.

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

A simulation file is a JSON file 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 simulator loads these files so you can develop and easily test your UI in all states without connecting to a real ChatGPT session.

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 and a default value. It returns an object with output (the tool result data), isLoading, isError, and other status fields. Your component renders the output as UI.

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 just like any React project. ChatGPT Apps also have access to CSS variables from the host for theme-aware styling.

How do I test a ChatGPT App locally?

Run sunpeak dev to start the local simulator at localhost:3000. The simulator loads your simulation files and renders your resources exactly as ChatGPT would. You can test different display modes (inline, fullscreen), themes (light, dark), and screen sizes. For automated testing, sunpeak includes Vitest and Playwright integration.

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

A ChatGPT App is an MCP App that runs inside ChatGPT, possibly with some ChatGPT-specific functionality. MCP (Model Context Protocol) Apps is the open standard that defines how apps communicate with AI hosts. When you build with sunpeak, you are building an MCP App that can run in ChatGPT, Claude, and other MCP-compatible hosts from a single codebase.