Awesome-omni-skill create-sunpeak-app
Use when working with sunpeak, or when the user asks to "build an MCP App", "build a ChatGPT App", "add a UI to an MCP tool", "create an interactive resource for Claude or ChatGPT", "build a React UI for an MCP server", or needs guidance on MCP App resources, tool-to-UI data flow, simulation files, host context, platform-specific ChatGPT/Claude features, or end-to-end testing of MCP App UIs.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/frontend/create-sunpeak-app" ~/.claude/skills/diegosouzapw-awesome-omni-skill-create-sunpeak-app && rm -rf "$T"
skills/frontend/create-sunpeak-app/SKILL.mdCreate Sunpeak App
Sunpeak is a React framework built on
@modelcontextprotocol/ext-apps for building MCP Apps with interactive UIs that run inside AI chat hosts (ChatGPT, Claude). It provides React hooks, a dev simulator, a CLI (sunpeak dev / sunpeak build), and a structured project convention.
Getting Reference Code
Clone the sunpeak repo for working examples:
git clone --depth 1 https://github.com/Sunpeak-AI/sunpeak /tmp/sunpeak
Template app lives at
/tmp/sunpeak/packages/sunpeak/template/. This is the canonical project structure — read it first.
Project Structure
my-sunpeak-app/ ├── src/ │ ├── resources/ │ │ └── {name}/ │ │ └── {name}-resource.tsx # Resource component + ResourceConfig export │ └── styles/ │ └── globals.css # Tailwind imports ├── tests/ │ ├── simulations/ │ │ └── {name}/ │ │ └── {name}-{scenario}-simulation.json # Simulation fixture files │ └── e2e/ │ └── {name}.spec.ts # Playwright tests ├── package.json └── (vite.config.ts, tsconfig.json, etc. managed by sunpeak CLI)
Discovery is convention-based:
- Resources:
src/resources/{name}/{name}-resource.tsx - Simulations:
tests/simulations/{name}/{name}-{scenario}-simulation.json
Resource Component Pattern
Every resource file exports two things:
— Aresource
object with MCP metadataResourceConfig- A named React component — The UI (
){Name}Resource
import { useToolData, useHostContext, useDisplayMode, SafeArea } from 'sunpeak'; import type { ResourceConfig } from 'sunpeak'; // MCP resource metadata export const resource: ResourceConfig = { name: 'weather', title: 'Weather', description: 'Show current weather conditions', mimeType: 'text/html;profile=mcp-app', _meta: { ui: { csp: { resourceDomains: ['https://cdn.example.com'], }, }, }, }; // Type definitions interface WeatherInput { city: string; units?: 'metric' | 'imperial'; } interface WeatherOutput { temperature: number; condition: string; humidity: number; } // React component export function WeatherResource() { // All hooks must be called before any early return const { input, output, isLoading } = useToolData<WeatherInput, WeatherOutput>(); const context = useHostContext(); const displayMode = useDisplayMode(); if (isLoading) return <div className="p-4 text-[var(--color-text-secondary)]">Loading...</div>; const isFullscreen = displayMode === 'fullscreen'; const hasTouch = context?.deviceCapabilities?.touch ?? false; return ( <SafeArea className={isFullscreen ? 'flex flex-col h-screen' : undefined}> <div className="p-4"> <h1 className="text-[var(--color-text-primary)] font-semibold">{input?.city}</h1> <p className={`${hasTouch ? 'text-base' : 'text-sm'} text-[var(--color-text-secondary)]`}> {output?.temperature}° — {output?.condition} </p> </div> </SafeArea> ); }
Rules:
- Always wrap in
to respect host insets<SafeArea> - Use MCP standard CSS variables via Tailwind arbitrary values:
,text-[var(--color-text-primary)]
,text-[var(--color-text-secondary)]
,bg-[var(--color-background-primary)]border-[var(--color-border-tertiary)]
— provide types for both input and outputuseToolData<TInput, TOutput>()- All hooks must be called before any early
(React rules of hooks)return - Do NOT mutate
directly inside hooks — useapp
for class setterseslint-disable-next-line react-hooks/immutability
Simulation Files
Simulations are JSON fixtures that power the dev simulator and MCP server. Place them at:
tests/simulations/{name}/{name}-{scenario}-simulation.json
{ "userMessage": "Show me the weather in Austin, TX.", "tool": { "name": "show-weather", "description": "Show current weather conditions", "inputSchema": { "type": "object", "properties": { "city": { "type": "string" }, "units": { "type": "string", "enum": ["metric", "imperial"] } }, "required": ["city"], "additionalProperties": false }, "annotations": { "readOnlyHint": true }, "_meta": { "ui": { "visibility": ["model", "app"] } } }, "toolInput": { "city": "Austin", "units": "imperial" }, "toolResult": { "structuredContent": { "temperature": 72, "condition": "Partly Cloudy", "humidity": 55 } } }
Key fields:
— Decorative text shown in simulator (no functional purpose)userMessage
— Full MCP Tool definition (used intool
)tools/list
— Arguments sent to the tool (shown as input totoolInput
)useToolData
— The data rendered bytoolResult.structuredContentuseToolData().output
— Text fallback for non-UI hoststoolResult.content[]
— Optional overrides forhostContext
(theme, locale, etc.)McpUiHostContext
Multiple simulations per resource are supported:
review-diff-simulation.json, review-post-simulation.json sharing the same resource.
Core Hooks Reference
All hooks are imported from
sunpeak:
| Hook | Returns | Description |
|---|---|---|
| | Reactive tool data from host |
| | Host context (theme, locale, capabilities, etc.) |
| | Current theme |
| | Current display mode (defaults to ) |
| | Safe area insets |
| | Host locale (e.g. ) |
| | Viewport dimensions |
| | True if viewport is mobile-sized |
| | Raw MCP App instance for direct SDK calls |
| | Returns a function to call a server-side tool by name |
| | Returns a function to send a message to the conversation |
| | Returns a function to open a URL through the host |
| | Request , , or ; check first |
| | Send debug log to host |
| | Register a teardown handler |
| | React state that auto-syncs to host model context via |
useRequestDisplayMode
details
useRequestDisplayModeconst { requestDisplayMode, availableModes } = useRequestDisplayMode(); // Always check availability before requesting if (availableModes?.includes('fullscreen')) { await requestDisplayMode('fullscreen'); } if (availableModes?.includes('pip')) { await requestDisplayMode('pip'); }
useCallServerTool
details
useCallServerToolconst callTool = useCallServerTool(); const result = await callTool({ name: 'get-weather', arguments: { city: 'Austin' } }); // result: { content?: [...], isError?: boolean }
useSendMessage
details
useSendMessageconst sendMessage = useSendMessage(); await sendMessage({ role: 'user', content: [{ type: 'text', text: 'Please refresh the data.' }], });
useAppState
details
useAppStateState is preserved in React and automatically sent to the host via
updateModelContext() after each update, so the LLM can see the current UI state in its context window.
const [state, setState] = useAppState<{ decision: 'accepted' | 'rejected' | null }>({ decision: null, }); // setState triggers a re-render AND pushes state to the model context setState({ decision: 'accepted' });
useToolData
details
useToolDataconst { input, // TInput | null — final tool input arguments inputPartial, // TInput | null — partial (streaming) input as it generates output, // TOutput | null — tool result (structuredContent ?? content) isLoading, // boolean — true until first toolResult arrives isError, // boolean — true if tool returned an error isCancelled, // boolean — true if tool was cancelled cancelReason, // string | null } = useToolData<MyInput, MyOutput>(defaultInput, defaultOutput);
Use
inputPartial for progressive rendering during LLM generation. Use output for the final data.
Commands
pnpm dev # Start dev server (Vite + MCP server, port 3000 web / 8000 MCP) pnpm build # Build all resources to dist/ pnpm test # Run unit tests (vitest) pnpm test:e2e # Run Playwright e2e tests
The
sunpeak dev command starts both the Vite dev server and the MCP server together. The simulator runs at http://localhost:3000. Connect ChatGPT to http://localhost:8000/mcp (or use ngrok for remote testing).
Production Build Output
sunpeak build generates optimized bundles in dist/, one folder per resource:
dist/ ├── weather/ │ ├── weather.html # Self-contained bundle (JS + CSS inlined) │ └── weather.json # ResourceConfig with generated uri for cache-busting ├── review/ │ ├── review.html │ └── review.json └── ...
The
.json file contains the ResourceConfig extracted from your .tsx file and a generated uri (e.g. ui://weather?v=abc123). Host both files and reference the .html in your production MCP server's registerAppResource call.
Platform Detection
import { isChatGPT, isClaude, detectPlatform } from 'sunpeak/platform'; // In a resource component function MyResource() { const platform = detectPlatform(); // 'chatgpt' | 'claude' | 'unknown' if (isChatGPT()) { // Safe to use ChatGPT-specific hooks } }
ChatGPT-Specific Hooks
Import from
sunpeak/platform/chatgpt. Always feature-detect before use.
import { useUploadFile, useRequestModal, useRequestCheckout } from 'sunpeak/platform/chatgpt'; import { isChatGPT } from 'sunpeak/platform'; function MyResource() { // Only call these when on ChatGPT const { upload } = useUploadFile(); const { open } = useRequestModal(); const { checkout } = useRequestCheckout(); }
| Hook | Description |
|---|---|
| Upload a file to ChatGPT, returns file ID |
| Get a download URL for an uploaded file |
| Open a host-native modal dialog |
| Trigger ChatGPT instant checkout |
SafeArea Component
Always wrap resource content in
<SafeArea> to respect host insets:
import { SafeArea } from 'sunpeak'; export function MyResource() { return ( <SafeArea> {/* your content */} </SafeArea> ); }
SafeArea applies padding equal to useSafeArea() insets automatically.
Styling with MCP Standard Variables
Use MCP standard CSS variables via Tailwind arbitrary values instead of raw colors. These variables adapt automatically to each host's theme (ChatGPT, Claude):
| Tailwind Class | CSS Variable | Usage |
|---|---|---|
| | Primary text |
| | Secondary/muted text |
| | Card/surface background |
| | Secondary/nested surface background |
| | Tertiary background |
| | Primary action color (e.g. badge fill) |
| | Subtle border |
| | Default border |
variant | — | Dark mode via |
These variables use CSS
light-dark() so they respond to theme changes automatically. The dark: Tailwind variant also works via [data-theme="dark"].
E2E Tests with Playwright
Critical: all resource content renders inside an
<iframe>. Always use page.frameLocator('iframe') for resource elements. Only the simulator chrome (header, #root) uses page.locator() directly.
import { test, expect } from '@playwright/test'; import { createSimulatorUrl } from 'sunpeak/chatgpt'; test('renders weather card', async ({ page }) => { await page.goto(createSimulatorUrl({ simulation: 'weather-show', theme: 'light' })); // Access elements INSIDE the resource iframe const iframe = page.frameLocator('iframe'); await expect(iframe.locator('h1')).toHaveText('Austin'); }); test('loads without console errors', async ({ page }) => { const errors: string[] = []; page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); }); await page.goto(createSimulatorUrl({ simulation: 'weather-show', theme: 'dark' })); // Wait for content to render const iframe = page.frameLocator('iframe'); await expect(iframe.locator('h1')).toBeVisible(); // Filter expected MCP handshake noise const unexpectedErrors = errors.filter( (e) => !e.includes('[IframeResource]') && !e.includes('mcp') && !e.includes('PostMessage') && !e.includes('connect') ); expect(unexpectedErrors).toHaveLength(0); });
createSimulatorUrl(params) builds the URL for a simulation. Full params:
| Param | Type | Description |
|---|---|---|
| | Simulation name without (e.g. ) |
| | Host shell (default: ) |
| | Color theme (default: ) |
| | Display mode (default: ) |
| | Locale string, e.g. |
| | Device type preset |
| | Enable touch capability |
| | Enable hover capability |
| | Safe area insets in pixels |
ResourceConfig Fields
import type { ResourceConfig } from 'sunpeak'; export const resource: ResourceConfig = { name: 'my-resource', // Unique resource name (kebab-case) title: 'My Resource', // Human-readable title description: 'What it shows', // Description for MCP hosts mimeType: 'text/html;profile=mcp-app', // Required for MCP App resources _meta: { ui: { csp: { resourceDomains: ['https://cdn.example.com'], // Image/script CDNs connectDomains: ['https://api.example.com'], // API fetch targets }, }, }, };
AppProvider (Library Use)
When using sunpeak as a library (without the CLI framework), wrap your app in
AppProvider to establish the MCP connection:
import { AppProvider, useApp } from 'sunpeak'; // AppProvider handles App creation, PostMessageTransport, and connection createRoot(document.getElementById('root')!).render( <AppProvider appInfo={{ name: 'MyApp', version: '1.0.0' }} capabilities={{}}> <MyApp /> </AppProvider> ); function MyApp() { const app = useApp(); // Reads from AppProvider context if (!app) return <div>Connecting...</div>; return <div>Connected!</div>; }
When using the sunpeak CLI (
sunpeak dev / sunpeak build), AppProvider wrapping is handled automatically by the framework's resource loader.
Common Mistakes
- Hooks before early returns — All hooks must run unconditionally. Move
/useMemo
above anyuseEffect
blocks.if (...) return - Missing
— Always wrap content in<SafeArea>
to respect host safe area insets.<SafeArea> - Wrong Playwright locator — Use
for resource content, neverpage.frameLocator('iframe').locator(...)
.page.locator(...) - Hardcoded colors — Use MCP standard CSS variables via Tailwind arbitrary values (
,text-[var(--color-text-primary)]
) not raw colors.bg-[var(--color-background-primary)] - Simulation name mismatch — The simulation key is the filename without
:-simulation.json
→carousel-show-simulation.json
.carousel-show - Mutating hook params — Use
foreslint-disable-next-line react-hooks/immutability
(class setter, not a mutation).app.onteardown = ... - Forgetting text fallback — Include
in simulations for non-UI hosts.toolResult.content[]