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 Connector 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.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "create-sunpeak-app" with this command: npx skills add sunpeak-ai/sunpeak/sunpeak-ai-sunpeak-create-sunpeak-app

Create 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 / sunpeak start), 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}.tsx            # Resource component + ResourceConfig export
│   ├── tools/
│   │   └── {name}.ts                 # Tool metadata, Zod schema, handler
│   ├── server.ts                     # Optional server entry (auth, config)
│   └── styles/
│       └── globals.css               # Tailwind imports
├── tests/
│   ├── simulations/
│   │   └── *.json                    # Simulation fixture files (flat directory)
│   └── 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}.tsx (name derived from directory)
  • Tools: src/tools/{name}.ts (name derived from filename)
  • Simulations: tests/simulations/*.json (flat directory, "tool" string references tool filename)

Resource Component Pattern

Every resource file exports two things:

  1. resource — A ResourceConfig object with MCP resource metadata (name is auto-derived from directory)
  2. A named React component — The UI ({Name}Resource)
import { useToolData, useHostContext, useDisplayMode, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';

// MCP resource metadata (name auto-derived from directory: src/resources/weather/)
export const resource: ResourceConfig = {
  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 <SafeArea> to respect host insets
  • 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)]
  • useToolData<TInput, TOutput>() — provide types for both input and output
  • All hooks must be called before any early return (React rules of hooks)
  • Do NOT mutate app directly inside hooks — use eslint-disable-next-line react-hooks/immutability for class setters

Tool Files

Each tool .ts file exports metadata, a Zod schema, and a handler. The resource field links a tool to its UI — omit it for data-only tools:

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

// 1. Tool metadata (resource links to src/resources/weather/ — omit for tools without a UI)
export const tool: AppToolConfig = {
  resource: 'weather',
  title: 'Show Weather',
  description: 'Show current weather conditions',
  annotations: { readOnlyHint: true },
  _meta: { ui: { visibility: ['model', 'app'] } },
};

// 2. Zod schema (auto-converted to JSON Schema for MCP)
export const schema = {
  city: z.string().describe('City name'),
  units: z.enum(['metric', 'imperial']).describe('Temperature units'),
};

// 3. Handler — return structured data for the UI
export default async function (args: { city: string; units?: string }, extra: ToolHandlerExtra) {
  return {
    structuredContent: {
      temperature: 72,
      condition: 'Partly Cloudy',
      humidity: 55,
    },
  };
}

Backend-Only Tools (Confirmation Loop)

A common pattern pairs a UI tool (for review) with a backend-only tool (for execution). The UI tool's structuredContent includes a reviewTool field. The resource component reads it and calls the backend tool via useCallServerTool when the user confirms:

// src/tools/review.ts — no resource field, shared by all review variants
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  title: 'Confirm Review',
  description: 'Execute or cancel a reviewed action after user approval',
  annotations: { readOnlyHint: false },
  _meta: { ui: { visibility: ['model', 'app'] } },
};

export const schema = {
  action: z.string().describe('Action identifier (e.g., "place_order", "apply_changes")'),
  confirmed: z.boolean().describe('Whether the user confirmed'),
  decidedAt: z.string().describe('ISO timestamp of decision'),
  payload: z.record(z.unknown()).optional().describe('Domain-specific data'),
};

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

export default async function (args: Args, _extra: ToolHandlerExtra) {
  if (!args.confirmed) {
    return {
      content: [{ type: 'text' as const, text: 'Cancelled.' }],
      structuredContent: { status: 'cancelled', message: 'Cancelled.' },
    };
  }
  return {
    content: [{ type: 'text' as const, text: 'Completed.' }],
    structuredContent: { status: 'success', message: 'Completed.' },
  };
}

The UI tool returns reviewTool in its response, and the resource calls useCallServerTool on accept/reject. The tool returns both content (human-readable text for the host model) and structuredContent (with status and message for the UI). The resource reads structuredContent.status to determine success/error styling and displays structuredContent.message. One review tool handles all review variants (purchases, diffs, posts) via the action field. The simulator returns mock simulation data for callServerTool calls, matching real host behavior. See the template's review resource for the full implementation.

Simulation Files

Simulations are JSON fixtures that power the dev simulator. Place them in tests/simulations/ as flat JSON files:

{
  "tool": "show-weather",
  "userMessage": "Show me the weather in Austin, TX.",
  "toolInput": {
    "city": "Austin",
    "units": "imperial"
  },
  "toolResult": {
    "structuredContent": {
      "temperature": 72,
      "condition": "Partly Cloudy",
      "humidity": 55
    }
  }
}

Key fields:

  • tool — String referencing a tool filename in src/tools/ (without .ts)
  • userMessage — Decorative text shown in simulator (no functional purpose)
  • toolInput — Arguments sent to the tool (shown as input to useToolData)
  • toolResult.structuredContent — The data rendered by useToolData().output
  • toolResult.content[] — Text fallback for non-UI hosts
  • serverTools — Mock responses for callServerTool calls. Keys are tool names. Values are either a single CallToolResult (always returned) or an array of { when, result } entries for conditional matching against call arguments.

Example with serverTools (for resources that call backend-only tools):

{
  "tool": "review-purchase",
  "toolResult": { "structuredContent": { "..." } },
  "serverTools": {
    "review": [
      { "when": { "confirmed": true }, "result": { "content": [{ "type": "text", "text": "Completed." }], "structuredContent": { "status": "success", "message": "Completed." } } },
      { "when": { "confirmed": false }, "result": { "content": [{ "type": "text", "text": "Cancelled." }], "structuredContent": { "status": "cancelled", "message": "Cancelled." } } }
    ]
  }
}

Multiple simulations per tool are supported: review-diff.json, review-post.json sharing the same resource via the same tool's resource field.

Core Hooks Reference

All hooks are imported from sunpeak:

HookReturnsDescription
useToolData<TIn, TOut>(){ input, inputPartial, output, isLoading, isError, isCancelled }Reactive tool data from host
useHostContext()McpUiHostContext | nullHost context (theme, locale, capabilities, etc.)
useTheme()'light' | 'dark' | undefinedCurrent theme
useDisplayMode()'inline' | 'pip' | 'fullscreen'Current display mode (defaults to 'inline')
useLocale()stringHost locale (e.g. 'en-US', defaults to 'en-US')
useTimeZone()stringIANA time zone (falls back to browser time zone)
usePlatform()'web' | 'desktop' | 'mobile' | undefinedHost-reported platform type
useDeviceCapabilities(){ touch?, hover? }Device input capabilities
useUserAgent()string | undefinedHost application identifier
useStyles()McpUiHostStyles | undefinedHost style configuration (CSS variables, fonts)
useToolInfo(){ id?, tool } | undefinedMetadata about the tool call that created this app
useSafeArea(){ top, right, bottom, left }Safe area insets (px)
useViewport(){ width, height, maxWidth, maxHeight }Container dimensions (px)
useIsMobile()booleanTrue if viewport is mobile-sized
useApp()App | nullRaw MCP App instance for direct SDK calls
useCallServerTool()(params) => Promise<result>Returns a function to call a server-side tool by name
useSendMessage()(params) => Promise<void>Returns a function to send a message to the conversation
useOpenLink()(params) => Promise<void>Returns a function to open a URL through the host
useRequestDisplayMode(){ requestDisplayMode, availableModes }Request 'inline', 'pip', or 'fullscreen'; check availableModes first
useDownloadFile()(params) => Promise<result>Download files through the host (works cross-platform)
useReadServerResource()(params) => Promise<result>Read a resource from the MCP server by URI
useListServerResources()(params?) => Promise<result>List available resources on the MCP server
useUpdateModelContext()(params) => Promise<void>Push state to the host's model context directly
useSendLog()(params) => Promise<void>Send debug log to host
useHostInfo(){ hostVersion, hostCapabilities }Host name, version, and supported capabilities
useTeardown(fn)voidRegister a teardown handler
useAppTools(config)voidRegister tools the app provides to the host (bidirectional tool calling)
useAppState(initial)[state, setState]React state that auto-syncs to host model context via updateModelContext()

useRequestDisplayMode details

const { requestDisplayMode, availableModes } = useRequestDisplayMode();

// Always check availability before requesting
if (availableModes?.includes('fullscreen')) {
  await requestDisplayMode('fullscreen');
}
if (availableModes?.includes('pip')) {
  await requestDisplayMode('pip');
}

useCallServerTool details

const callTool = useCallServerTool();

const result = await callTool({ name: 'get-weather', arguments: { city: 'Austin' } });
// result: { content?: [...], isError?: boolean }

useSendMessage details

const sendMessage = useSendMessage();

await sendMessage({
  role: 'user',
  content: [{ type: 'text', text: 'Please refresh the data.' }],
});

useAppState details

State 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

const {
  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.

useDownloadFile details

const downloadFile = useDownloadFile();

// Download embedded text content
await downloadFile({
  contents: [{
    type: 'resource',
    resource: {
      uri: 'file:///export.json',
      mimeType: 'application/json',
      text: JSON.stringify(data, null, 2),
    },
  }],
});

// Download embedded binary content
await downloadFile({
  contents: [{
    type: 'resource',
    resource: {
      uri: 'file:///image.png',
      mimeType: 'image/png',
      blob: base64EncodedPng,
    },
  }],
});

useReadServerResource / useListServerResources details

const readResource = useReadServerResource();
const listResources = useListServerResources();

// List available resources
const result = await listResources();
for (const resource of result?.resources ?? []) {
  console.log(resource.name, resource.uri);
}

// Read a specific resource by URI
const content = await readResource({ uri: 'videos://bunny-1mb' });

useAppTools details

Register tools the app provides to the host for bidirectional tool calling. Requires tools capability.

import { useAppTools } from 'sunpeak';

function MyResource() {
  useAppTools({
    tools: [{
      name: 'get-selection',
      description: 'Get current user selection',
      inputSchema: { type: 'object', properties: {} },
      handler: async () => ({
        content: [{ type: 'text', text: selectedText }],
      }),
    }],
  });
}

Commands

pnpm dev      # Start dev server (Vite + MCP server, port 3000 web / 8000 MCP)
pnpm build    # Build resources + compile tools to dist/
pnpm start    # Start production MCP server (real handlers, auth, Zod validation)
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).

Use sunpeak build && sunpeak start to test production behavior locally with real handlers instead of simulation fixtures.

The sunpeak dev command supports two orthogonal flags for testing different combinations:

  • --prod-tools — Route callServerTool to real tool handlers instead of simulation mocks
  • --prod-resources — Serve production-built HTML from dist/ instead of Vite HMR
  • --prod-tools --prod-resources — Full smoke test: production bundles with real handlers

Production Server Options

sunpeak start                          # Default: port 8000, all interfaces
sunpeak start --port 3000              # Custom port
sunpeak start --host 127.0.0.1         # Bind to localhost only
sunpeak start --json-logs              # Structured JSON logging
PORT=3000 HOST=127.0.0.1 sunpeak start # Via environment variables

The production server provides:

  • /health — Health check endpoint ({"status":"ok","uptime":N}) for load balancer probes and monitoring
  • /mcp — MCP Streamable HTTP endpoint
  • Graceful shutdown on SIGTERM/SIGINT (5-second drain)
  • Structured JSON logging (--json-logs) for log aggregation (Datadog, CloudWatch, etc.)

Production Build Output

sunpeak build generates optimized bundles in dist/:

dist/
├── weather/
│   ├── weather.html   # Self-contained bundle (JS + CSS inlined)
│   └── weather.json   # ResourceConfig with generated uri for cache-busting
├── tools/
│   ├── show-weather.js  # Compiled tool handler + Zod schema
│   └── ...
├── server.js          # Compiled server entry (if src/server.ts exists)
└── ...

sunpeak start loads everything from dist/ and starts a production MCP server with real tool handlers, Zod input validation, and optional auth from src/server.ts.

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();
}
HookDescription
useUploadFile()Upload a file to ChatGPT, returns file ID
useGetFileDownloadUrl(fileId)Deprecated — use useDownloadFile() from sunpeak instead
useRequestModal(params)Open a host-native modal dialog
useRequestCheckout(session)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 ClassCSS VariableUsage
text-[var(--color-text-primary)]--color-text-primaryPrimary text
text-[var(--color-text-secondary)]--color-text-secondarySecondary/muted text
bg-[var(--color-background-primary)]--color-background-primaryCard/surface background
bg-[var(--color-background-secondary)]--color-background-secondarySecondary/nested surface background
bg-[var(--color-background-tertiary)]--color-background-tertiaryTertiary background
bg-[var(--color-ring-primary)]--color-ring-primaryPrimary action color (e.g. badge fill)
border-[var(--color-border-tertiary)]--color-border-tertiarySubtle border
border-[var(--color-border-primary)]--color-border-primaryDefault border
dark: variantDark mode via [data-theme="dark"]

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: 'show-weather', 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: 'show-weather', 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:

ParamTypeDescription
simulationstringSimulation filename without .json (e.g. 'show-weather')
host'chatgpt' | 'claude'Host shell (default: 'chatgpt')
theme'light' | 'dark'Color theme (default: 'dark')
displayMode'inline' | 'pip' | 'fullscreen'Display mode (default: 'inline')
localestringLocale string, e.g. 'en-US'
deviceType'mobile' | 'tablet' | 'desktop'Device type preset
touchbooleanEnable touch capability
hoverbooleanEnable hover capability
safeAreaTop/Bottom/Left/RightnumberSafe area insets in pixels

ResourceConfig Fields

import type { ResourceConfig } from 'sunpeak';

// name is auto-derived from the directory (src/resources/my-resource/)
export const resource: ResourceConfig = {
  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
      },
    },
  },
};

Common Mistakes

  1. Hooks before early returns — All hooks must run unconditionally. Move useMemo/useEffect above any if (...) return blocks.
  2. Missing <SafeArea> — Always wrap content in <SafeArea> to respect host safe area insets.
  3. Wrong Playwright locator — Use page.frameLocator('iframe').locator(...) for resource content, never page.locator(...).
  4. Hardcoded colors — Use MCP standard CSS variables via Tailwind arbitrary values (text-[var(--color-text-primary)], bg-[var(--color-background-primary)]) not raw colors.
  5. Simulation tool mismatch — The "tool" field in simulation JSON must match a tool filename in src/tools/ (e.g. "tool": "show-weather" matches src/tools/show-weather.ts).
  6. Mutating hook params — Use eslint-disable-next-line react-hooks/immutability for app.onteardown = ... (class setter, not a mutation).
  7. Forgetting text fallback — Include toolResult.content[] in simulations for non-UI hosts.

References

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

Leads

Leads - command-line tool for everyday use

Registry SourceRecently Updated
General

Bmi Calculator

BMI计算器。BMI计算、理想体重、健康计划、体重追踪、儿童BMI、结果解读。BMI calculator with ideal weight, health plan. BMI、体重、健康。

Registry SourceRecently Updated
General

Blood

Blood — a fast health & wellness tool. Log anything, find it later, export when needed.

Registry SourceRecently Updated
General

Better Genshin Impact

📦BetterGI · 更好的原神 - 自动拾取 | 自动剧情 | 全自动钓鱼(AI) | 全自动七圣召唤 | 自动伐木 | 自动刷本 | 自动采集/挖矿/锄地 | 一条龙 | 全连音游 - UI A better genshin impact, c#, auto-play-game, automatic, g...

Registry SourceRecently Updated