All posts

How to Build a Claude App (July 2026)

Abe Wheeler
Claude AppsMCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkClaude ConnectorsClaude Connector FrameworkClaude Connector Testing
How to build a Claude App!

How to build a Claude App!

TL;DR: Build a Claude App as an interactive Claude Connector backed by MCP Apps. Start with one tool, one rendered resource, a typed structuredContent contract, and an outputSchema. Test it locally with simulations before you connect a deployed remote MCP server to Claude.

The term “Claude App” has settled into developer shorthand, but the current Claude path is more specific: Claude connects to remote MCP servers through Claude Connectors, and interactive connectors can render app UI through the MCP Apps pattern.

That means the build path is not a separate Claude-only SDK. You build an MCP server with tools, resources, and app metadata. Claude calls the tool, receives a structured result, and renders the linked UI resource in a sandboxed frame. If you keep the app on the shared MCP Apps contract, the same core code can also run in ChatGPT and other hosts that support MCP App resources.

This guide shows the current architecture, the smallest useful build, and the checks I would run before shipping.

What You Are Building

A Claude App has three pieces:

  1. A remote MCP server Claude can reach over HTTPS.
  2. A tool Claude can call when the user asks for something.
  3. A UI resource that renders the tool result as an app inside the chat.

The official MCP Apps model is built around tools and resources. A tool does the server work. A resource is the UI the host can render. The tool result carries normal MCP result fields such as content, structuredContent, and _meta, and the app metadata points the host at the resource to open.

For a Claude user, the result feels like an app. They ask for a dashboard, invoice review, project plan, code diff, catalog, or approval screen, and Claude opens an interface they can use without leaving the conversation.

For you, the important part is the contract:

  • The model sees the tool name, description, input schema, annotations, and safe model-readable result data.
  • The resource sees the typed render data through the host bridge.
  • The iframe stays isolated from the host page.
  • Host-specific features stay optional, because the portable MCP path should work first.

Start With One Tool and One Resource

The first version should be boring. Do not begin with OAuth, pagination, background jobs, file uploads, and write actions at the same time. Build one read-only tool that returns a stable object, then render that object in one resource.

With sunpeak, a resource is a React component plus a small config object:

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

export const resource: ResourceConfig = {
  description: 'Display account health metrics for the selected workspace',
};

type AccountHealth = {
  workspace: string;
  score: number;
  risks: Array<{ id: string; label: string; severity: 'low' | 'medium' | 'high' }>;
};

export function AccountHealthResource() {
  const { output, isLoading, isError } = useToolData<unknown, AccountHealth>();

  if (isLoading) return <SafeArea className="p-4">Loading account health...</SafeArea>;
  if (isError || !output) return <SafeArea className="p-4">Unable to load account health.</SafeArea>;

  return (
    <SafeArea className="space-y-4 p-4 font-sans">
      <header>
        <p className="text-sm text-gray-500">{output.workspace}</p>
        <h1 className="text-2xl font-semibold">Health score: {output.score}</h1>
      </header>

      <ul className="space-y-2">
        {output.risks.map((risk) => (
          <li key={risk.id} className="rounded border p-3">
            <span className="font-medium">{risk.label}</span>
            <span className="ml-2 text-sm text-gray-500">{risk.severity}</span>
          </li>
        ))}
      </ul>
    </SafeArea>
  );
}

The important part is not the UI. The important part is that the component reads one typed payload from useToolData(). Keep that payload small enough for the model to reason about, and stable enough to test.

Connect the Tool

The tool is the server-side action Claude can call. It validates arguments, checks auth, fetches data, and returns the data your resource renders.

In sunpeak, tools live in src/tools/*.ts and can point at a resource by name:

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

export const tool: AppToolConfig = {
  resource: 'account-health',
  title: 'Show Account Health',
  description: 'Show account health metrics and current risks for a workspace.',
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
};

export const schema = {
  workspaceId: z.string().describe('Workspace ID to inspect.'),
};

export const outputSchema = {
  workspace: z.string(),
  score: z.number(),
  risks: z.array(
    z.object({
      id: z.string(),
      label: z.string(),
      severity: z.enum(['low', 'medium', 'high']),
    })
  ),
};

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

export default async function showAccountHealth(args: Args, _extra: ToolHandlerExtra) {
  return {
    content: [{ type: 'text', text: `Loaded account health for ${args.workspaceId}.` }],
    structuredContent: {
      workspace: args.workspaceId,
      score: 82,
      risks: [
        { id: 'r1', label: 'Three stale escalations', severity: 'medium' },
        { id: 'r2', label: 'Renewal owner missing', severity: 'high' },
      ],
    },
  };
}

There are four details worth copying into production code:

  • resource links the tool to the rendered UI.
  • schema tells the model what arguments it can pass.
  • outputSchema describes the structuredContent shape.
  • annotations tell the host whether the tool is read-only, destructive, or open-world.

For a hand-rolled MCP server, the same concepts map to MCP tool descriptors, app resource metadata, and a tool result that points at a ui:// resource. sunpeak handles that registration and bundling for you, which is why the example stays short.

Treat Tool Results as Separate Lanes

Most Claude App bugs come from mixing result fields.

Use content for a short model-readable summary. Use structuredContent for the JSON object your resource renders and the model may use in follow-up reasoning. Use _meta for UI-only data when the target host supports it.

That split matters because structuredContent can become model context. Do not put secrets, large hidden payloads, signed URLs, internal permission data, or long raw documents there. If the UI needs a cursor for a Next button and the model does not, keep it out of structuredContent.

A practical result shape looks like this:

return {
  content: [{ type: 'text', text: 'Found 12 open invoices.' }],
  structuredContent: {
    count: 12,
    invoices: [
      { id: 'inv_123', customer: 'Acme', amount: 4200, status: 'overdue' },
    ],
  },
  _meta: {
    nextCursor: 'eyJwYWdlIjoyfQ',
    rowPermissions: {
      inv_123: ['view', 'comment'],
    },
  },
};

Then test each lane separately. A contract test should fail if structuredContent stops matching outputSchema. An E2E test should fail if the resource cannot render the same payload. A security test should fail if private values leak into model-visible content.

Add Interactivity After the Read-Only Path Works

A static rendered result is useful, but most real Claude Apps need user actions. Common actions include:

  • Filtering a table.
  • Selecting rows and asking Claude to explain them.
  • Opening detail panels.
  • Calling another server tool from the UI.
  • Sending a message back into the conversation.
  • Updating model context after a user interaction.

With sunpeak, useAppState is the first interaction primitive to reach for. It works like React state, but the host can see the state you choose to sync:

import { useAppState, useToolData } from 'sunpeak';

export function RiskList() {
  const { output } = useToolData<unknown, { risks: Array<{ id: string; severity: string }> }>();
  const [severity, setSeverity] = useAppState('severityFilter', 'all');

  const risks = output?.risks.filter((risk) => severity === 'all' || risk.severity === severity) ?? [];

  return (
    <section>
      <select value={severity} onChange={(event) => setSeverity(event.target.value)}>
        <option value="all">All</option>
        <option value="high">High</option>
        <option value="medium">Medium</option>
      </select>

      <p>{risks.length} risks visible</p>
    </section>
  );
}

Only sync state the model needs. A selected record, active filter, user-confirmed draft, or current step in a workflow can help Claude answer the next question. A hover state, local tab animation, or scroll position usually should stay inside React.

For server actions triggered from the UI, use the app bridge through useCallServerTool. For conversation actions, use useSendMessage or the equivalent host-supported request. Feature-detect optional host capabilities before you depend on them.

Design for Claude and Other Hosts

Claude and ChatGPT can both render MCP App-style resources, but host behavior is still not identical. Build the shared layer first:

  • Render from structuredContent, not from host globals.
  • Use SafeArea or host safe-area values so controls do not sit under host chrome.
  • Read display mode and viewport before assuming a layout.
  • Test light and dark themes.
  • Keep external network calls behind declared CSP and CORS settings.
  • Keep host-only APIs behind feature checks.

Display modes deserve special care. A dense dashboard may work in fullscreen but fail inline. A review form may need a compact summary in inline mode and full controls after the user expands it. Write those as explicit UI states, then test them.

import { useDisplayMode, useRequestDisplayMode } from 'sunpeak';

export function DashboardShell() {
  const displayMode = useDisplayMode();
  const requestDisplayMode = useRequestDisplayMode();

  if (displayMode === 'inline') {
    return (
      <button type="button" onClick={() => requestDisplayMode('fullscreen')}>
        Open dashboard
      </button>
    );
  }

  return <FullDashboard />;
}

Do not assume every host will grant every display request. Treat the request as a preference, then keep the UI usable in the current mode.

Local Development Without a Claude Account

The slow path is to deploy, connect Claude, ask the same prompt, and refresh after every change. That is exactly the loop you want to avoid.

With sunpeak:

npx sunpeak new claude-app
cd claude-app
pnpm dev

The dev server gives you a local inspector and MCP server. The inspector can render the same resource under Claude and ChatGPT runtime replicas, so you can check host context, display modes, themes, safe areas, tool results, and app state before a real host sees the app.

For an existing MCP server:

npx sunpeak inspect --server http://localhost:8000/mcp

That is useful when your backend is already in Python, Go, Rust, or another TypeScript server. You can inspect the server without moving the whole app into a sunpeak project.

Write Simulations Before Writing E2E Tests

A simulation file pins the state you want the app to render:

{
  "tool": "show_account_health",
  "userMessage": "Show account health for workspace acme-prod",
  "toolInput": { "workspaceId": "acme-prod" },
  "toolResult": {
    "content": [{ "type": "text", "text": "Loaded account health for acme-prod." }],
    "structuredContent": {
      "workspace": "acme-prod",
      "score": 82,
      "risks": [
        { "id": "r1", "label": "Three stale escalations", "severity": "medium" },
        { "id": "r2", "label": "Renewal owner missing", "severity": "high" }
      ]
    }
  }
}

Create simulations for the states that usually break:

  • Empty data.
  • Long labels.
  • Permission denied.
  • Partial data.
  • Server error.
  • High-volume result.
  • Narrow viewport.
  • Dark theme.

Then turn the most important simulations into Playwright tests:

import { expect, test } from 'sunpeak/test';

test('account health renders high-risk items', async ({ inspector }) => {
  const result = await inspector.renderTool('show_account_health');

  await expect(result.app().getByText('Health score: 82')).toBeVisible();
  await expect(result.app().getByText('Renewal owner missing')).toBeVisible();
});

This catches the problems normal React tests miss: the server result, app metadata, host iframe, bridge data, theme, display mode, and rendered UI all have to agree.

Production Checks Before You Connect Claude

Before adding a deployed server to Claude, run through this checklist:

  • The MCP endpoint is public HTTPS and stable.
  • The server supports the transport Claude expects for remote MCP.
  • OAuth callback URLs match Claude’s connector flow if the connector needs auth.
  • Every model-facing tool has clear descriptions and tight input schemas.
  • Tools that return structuredContent declare outputSchema.
  • Write or destructive tools use the right annotations and confirmation flow.
  • Resource metadata declares CSP domains for any external API, image, font, or frame.
  • The app works in inline and fullscreen layouts, with a fallback when picture-in-picture is unavailable.
  • The app renders in light and dark themes.
  • Large result sets are paginated or summarized.
  • Secrets stay on the server or in host-approved auth flows.
  • E2E tests cover the main resource states.
  • A small live test confirms the deployed server works in Claude.

The live test should be narrow. Use it to prove Claude can connect, authenticate, call the right tool, and render the deployed resource. Keep broad coverage in local deterministic tests so CI can run it without paid host accounts or AI credits.

What sunpeak Adds

You can build this stack manually. Use an MCP SDK, register tools and resources, bundle React, serve the resource HTML, wire the app bridge, write Playwright tests, and build your own fixtures.

sunpeak packages those pieces because the hard part is not writing a React card. The hard part is proving the tool result, resource metadata, iframe, host bridge, display modes, theme, state, and deployment path all work together.

Current sunpeak projects include:

  • npx sunpeak new for a full MCP App project.
  • npx sunpeak inspect --server URL for existing MCP servers.
  • A local inspector with Claude and ChatGPT runtime replicas.
  • Simulation fixtures for deterministic UI states.
  • Unit, E2E, visual, live host, and eval test scaffolding.
  • Portable React hooks such as useToolData, useAppState, useDisplayMode, and useHostContext.

If you are building your first Claude App, start with the portable MCP App layer. Add Claude-specific connector setup when the local tool, resource, state, and tests already work.

The shortest path is still:

npx sunpeak new claude-app
cd claude-app
pnpm dev

Then replace the sample tool with your first real tool, keep the result contract small, and add one simulation for each state you would otherwise test by hand.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What is a Claude App?

A Claude App is the practical name developers use for an interactive Claude Connector that renders UI inside Claude. Under the hood, it is an MCP App: an MCP server exposes a tool, the tool returns structuredContent, and the host renders a linked UI resource in a sandboxed iframe.

Is a Claude App the same thing as a Claude Connector?

A Claude Connector is the broader Claude integration surface for remote MCP servers. Some connectors only expose tools and data. A Claude App is the interactive version of that pattern, where a tool result also opens a rendered app resource such as a dashboard, form, table, editor, or review screen.

Can one Claude App also run in ChatGPT?

Yes, if you build against the portable MCP Apps contract. Keep the core app code on standard concepts such as tools, resources, structuredContent, _meta, display modes, host context, and app state. Use host-specific APIs only behind feature detection or separate imports.

What is the smallest useful Claude App architecture?

Start with one read-only tool that returns typed structuredContent and one resource that renders that data. Add an outputSchema for the tool result, keep model-readable facts in structuredContent, put UI-only helper data in _meta when the host supports it, and test the rendered resource in a local host replica before deploying.

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

No. You can build and test the tool, resource, display modes, themes, and data states locally with sunpeak. A real Claude account is only needed when you validate the deployed remote MCP server inside Claude.

How do I test a Claude App locally?

Use simulation files and inspector E2E tests. A simulation pins the user message, tool input, tool result, structuredContent, and optional _meta. The local inspector renders that state inside Claude and ChatGPT runtime replicas, and Playwright tests can assert the iframe UI with stable data.

What should go in structuredContent versus _meta?

Put concise, public data the model may reason about in structuredContent. Put UI-only helper data in _meta when the target host supports it, such as pagination cursors, signed asset references, row IDs, view hints, or analytics flags that should not enter the model transcript.

What framework should I use for a Claude App?

You can build a Claude App with plain MCP SDKs, React, and your own resource bundling. sunpeak packages the common workflow: project scaffolding, MCP server wiring, resource registration, a local Claude and ChatGPT inspector, simulation files, E2E tests, visual tests, live host checks, and evals.