ChatGPT App Tutorial: Build Your First App in 5 Minutes
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 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 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 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
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}.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 } = 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:
resourcetells ChatGPT the name and purpose of your app. See the resource registration docs for all config options.useToolDatais 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 ourRecipeDatashape.SafeAreawraps your content with proper insets so it renders correctly across display modes.- The
if (!output) return nullcheck handles the brief moment before data arrives.
The rest is standard React with Tailwind classes. Nothing framework-specific about the UI itself.
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:
toollinks this tool to the recipe resource and describes it to ChatGPT. Theannotations.readOnlyHinttells the model this tool only reads data (no side effects).schemadefines the tool’s input parameters using Zod. ChatGPT uses the descriptions to decide what arguments to pass.- The default export is the handler that runs when ChatGPT calls the tool. It returns
structuredContentmatching the shape your resource expects.
The resource import connects the tool to the resource you created in Step 2. When ChatGPT calls show-recipe, it renders your RecipeResource component with whatever structuredContent the handler returns.
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 would let you switch between test cases in the inspector.
Step 5: Run the Inspector
Start the development server:
sunpeak dev
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 work in 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 is:
- The user asks ChatGPT something (“Give me a recipe for cookies”).
- ChatGPT decides to call a tool (
show-recipe). Your MCP server executes the tool and returns structured data. - ChatGPT passes that data to your resource component via
structuredContent. - Your component receives it through
useToolDataand 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. 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 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
pnpm add -g sunpeak && sunpeak new
Further Reading
- How to run your ChatGPT App locally - covers project structure and the inspector in more detail.
- The complete guide to testing ChatGPT Apps - shows how to write unit tests and end-to-end tests.
- How to debug ChatGPT Apps - walks through common errors and how to fix them.
- sunpeak quickstart
- MCP Apps documentation - has the full API reference for all hooks and components.
- MCP Overview
- Tools
- Resources
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 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 inspector. 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 inspector 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 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), themes (light, dark), and screen sizes. For automated testing, sunpeak includes a built-in testing framework.
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.