---
name: Create MCP App
description: This skill should be used when the user asks to "create an MCP App", "add a UI to an MCP tool", "build an interactive MCP View", "scaffold an MCP App", or needs guidance on MCP Apps SDK patterns, UI-resource registration, MCP App lifecycle, or host integration. Provides comprehensive guidance for building MCP Apps with interactive UIs.
---

# Create MCP App

Build interactive UIs that run inside MCP-enabled hosts like Claude Desktop, Goose, VS Code. An MCP App combines an MCP tool with an HTML resource to display rich, interactive content.

## Core Concept: Tool + Resource

Every MCP App requires two parts linked together:

1. **Tool** - Called by the LLM/host, returns data
2. **Resource** - Serves the bundled HTML UI that displays the data
3. **Link** - The tool's `_meta.ui.resourceUri` references the resource

```
Host calls tool → Server returns result → Host renders resource UI → UI receives result
```

## Architecture Decision: Direct Composition vs Dynamic Rendering

### ✅ RECOMMENDED: Direct Component Composition (Per-App Files)

Each MCP App is a standalone Vite-bundled HTML file that directly imports and composes the components it needs. The layout is hardcoded — no runtime interpretation layer.

```tsx
// pipeline-board-app.tsx
import { PageHeader } from '../components/layout/PageHeader';
import { KanbanBoard } from '../components/data/KanbanBoard';
import { MetricCard } from '../components/data/MetricCard';

export function PipelineBoardApp({ data }) {
  return (
    <PageHeader title={data.pipeline.name}>
      <KanbanBoard columns={data.stages} />
    </PageHeader>
  );
}
```

**Why this works:**
- Each app knows exactly what it renders — no dynamic interpretation
- React state is stable (no tree replacement issues)
- Easy to debug — one app, one file, one purpose
- Shared component library across all apps via imports
- Vite bundles each app into a single HTML file

### ❌ AVOID: Dynamic JSON Tree Rendering

Do NOT build a universal renderer that receives a JSON UI tree at runtime and dynamically maps type strings to components. This approach causes:

- **State destruction** — Every tool result replaces the entire tree, unmounting all React components and destroying local state (forms, drag positions, open dropdowns)
- **Key instability** — Dynamically generated keys cause React to unmount/remount entire subtrees
- **Silent failures** — Registry lookups for unknown types fail silently
- **Debugging nightmare** — Issues are in the interpretation layer, not the components

If you MUST use dynamic rendering (e.g., AI-generated UIs), apply these mitigations:
- Use `mergeUITrees(prev, next)` instead of wholesale tree replacement
- Ensure ALL element keys are semantic and deterministic (`"pipeline-kanban"` not `"el-${i}"`)
- Separate data-result tool calls from navigation-intent tool calls (only navigation should replace the tree)

## Interactivity Patterns (Ranked by Reliability)

### Pattern 1: Client-Side State Only ⭐ MOST RELIABLE
Send all data upfront via `ontoolresult`. The UI handles ALL interactions locally with React state. No server calls needed for sorting, filtering, drag-drop, tab switching, form editing, etc.

```tsx
// All interactivity is local — works on EVERY host
const [items, setItems] = useState(data.items);
const onDragEnd = (draggedId, targetColumn) => {
  setItems(prev => prev.map(item => 
    item.id === draggedId ? { ...item, column: targetColumn } : item
  ));
};
```

**Use for:** DataTable sorting/filtering, KanbanBoard drag-drop, TabGroup switching, form editing, chart interactions — anything that doesn't NEED fresh server data.

### Pattern 2: `callServerTool` for On-Demand Data
Only use when the UI needs fresh data FROM the server (not for writes). Requires host support.

```tsx
const loadMore = async () => {
  const result = await app.callServerTool({ 
    name: "search_contacts", 
    arguments: { query, offset: page * 25 } 
  });
  setContacts(prev => [...prev, ...result.contacts]);
};
```

**Use for:** Pagination, search-as-you-type, expanding tree nodes, loading related records.

### Pattern 3: `updateModelContext` for Background Sync
Silently inform the model what the user did in the UI. No visible message appears in chat. The model has this context for its next interaction.

```tsx
const onUserAction = (action) => {
  // Update local state immediately
  setLocalState(newState);
  // Silently tell the model
  app.updateModelContext({ 
    text: `User moved Deal "${deal.name}" to stage "${newStage}"` 
  });
};
```

**Use for:** Tracking user interactions, keeping model informed of UI state changes.

### Pattern 4: `sendMessage` for Triggering Model Actions
Sends a visible message in the conversation that triggers the model to respond and take action.

```tsx
const onSave = () => {
  app.sendMessage({ 
    text: `Please save these changes:\n${getChangesSummary()}` 
  });
};
```

**Use for:** Explicit save/submit actions, batch syncing changes to server via the model.

### Pattern 5: App-Only Tools (`visibility: ["app"]`)
Tools hidden from the model, only callable from the UI. Useful for UI-specific server operations.

```tsx
_meta: { ui: { resourceUri, visibility: ["app"] } }
```

**Use for:** Polling, refresh buttons, pagination controls, form submissions.

## Hybrid Interactivity (Recommended for Write Operations)

For operations that need to persist to a server (creating invoices, updating deals, etc.), use capability detection to choose the best path:

```tsx
function useSmartAction() {
  const { app } = useMCPApp();
  const canCallTools = !!app?.getHostCapabilities()?.serverTools;

  const executeAction = async (toolName, args, description) => {
    if (canCallTools) {
      // Direct path: call server tool immediately
      try {
        return await app.callServerTool({ name: toolName, arguments: args });
      } catch (err) {
        // Fallback on failure
        app.updateModelContext({ text: `Action failed, please retry: ${description}` });
      }
    } else {
      // Fallback: track locally, auto-save via sendMessage after debounce
      trackChange({ toolName, args, description });
      app.updateModelContext({ text: `User action: ${description}` });
    }
  };

  return { executeAction, canCallTools };
}
```

**Flow on hosts WITH `callServerTool`:** button click → server call → instant result
**Flow on hosts WITHOUT `callServerTool`:** button click → local state update → auto-save after 3s idle → model executes writes

## Host Compatibility Matrix

As of **2026-01-26** (MCP Apps v1.0.1 — first official stable release):

| Host | Renders UI | `callServerTool` | `updateModelContext` | `sendMessage` | Transport |
|------|-----------|-------------------|----------------------|---------------|-----------|
| Claude Desktop | ✅ | ✅ | ✅ | ✅ | stdio |
| Claude Web | ✅ | ✅ | ✅ | ✅ | HTTP |
| ChatGPT | ✅ | ✅ | ✅ | ✅ | HTTP |
| VS Code Insiders | ✅ | ✅ | ✅ | ✅ | stdio |
| Goose | ✅ | ⚠️ Partial | ✅ | ✅ | stdio/HTTP |
| Postman | ✅ | ✅ | ✅ | ✅ | HTTP |
| MCPJam | ✅ | ✅ | ✅ | ✅ | HTTP |
| JetBrains IDEs | 🔜 Coming | — | — | — | stdio |

**Rule:** Design for Pattern 1 (client-side state) first. Layer on `callServerTool` as progressive enhancement.
**Transport rule:** Support BOTH stdio and HTTP in your server entry point — Claude Desktop and VS Code use stdio, web hosts use HTTP.

## PostMessage Bridge Protocol

MCP Apps use **JSON-RPC 2.0 over `window.postMessage`**:

- App → Host: `{ jsonrpc: "2.0", id: N, method: "tools/call", params: { name, arguments } }`
- Host → App: `{ jsonrpc: "2.0", id: N, result: { content: [...] } }`
- Validates `event.source` (Window identity, NOT origin string)
- Invalid messages are silently dropped (Zod validation)

**Key requirements for `callServerTool` to work:**
1. Host must declare `serverTools: {}` in `hostCapabilities` during `ui/initialize`
2. Host must register `oncalltool` handler on `AppBridge`
3. Host must relay `tools/call` requests to the real MCP server

**Known failure modes:**
- `srcdoc` iframes get origin `"null"` — use `document.write()` instead
- Init handshake (`ui/initialize`) never completing — `callServerTool` hangs forever
- Double-iframe `event.source` mismatch — messages silently dropped

## Quick Start Decision Tree

### Framework Selection

| Framework | SDK Support | Best For |
|-----------|-------------|----------|
| React | `useApp` hook provided | Teams familiar with React |
| Vanilla JS | Manual lifecycle | Simple apps, no build complexity |
| Vue/Svelte/Preact/Solid | Manual lifecycle | Framework preference |

### Project Structure (Multi-App Server)

For servers with many views (like a CRM), share components across apps:

```
src/
├── components/          # Shared component library
│   ├── layout/          # PageHeader, Card, SplitLayout, StatsGrid, Section
│   ├── data/            # DataTable, KanbanBoard, MetricCard, Timeline, etc.
│   ├── charts/          # BarChart, LineChart, PieChart, FunnelChart
│   ├── interactive/     # ContactPicker, InvoiceBuilder, FormGroup
│   └── shared/          # ActionButton, SearchBar, Toast, Modal
├── hooks/               # useCallTool, useSmartAction, useHostCapabilities
├── styles/              # base.css, interactive.css
├── apps/                # Individual app entry points
│   ├── contact-grid/    # Contact list app
│   │   ├── App.tsx
│   │   ├── index.html
│   │   └── vite.config.ts
│   ├── pipeline-board/  # Kanban board app
│   │   ├── App.tsx
│   │   ├── index.html
│   │   └── vite.config.ts
│   └── invoice-preview/ # Invoice view app
│       ├── App.tsx
│       ├── index.html
│       └── vite.config.ts
├── server.ts            # MCP server with all tools + resources
└── package.json
```

Each app has its own `vite.config.ts` with `vite-plugin-singlefile`, outputting to `dist/app-ui/{app-name}.html`.

## Getting Reference Code

**SDK Version:** `@modelcontextprotocol/ext-apps` v1.0.1 (Stable spec: 2026-01-26)
**Spec:** [SEP-1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) — first official MCP extension

Clone the SDK repository for working examples and API documentation:

```bash
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
```

### Framework Templates

Learn and adapt from `/tmp/mcp-ext-apps/examples/basic-server-{framework}/`:

| Template | Key Files |
|----------|-----------|
| `basic-server-vanillajs/` | `server.ts`, `src/mcp-app.ts`, `mcp-app.html` |
| `basic-server-react/` | `server.ts`, `src/mcp-app.tsx` (uses `useApp` hook) |
| `basic-server-vue/` | `server.ts`, `src/App.vue` |
| `basic-server-svelte/` | `server.ts`, `src/App.svelte` |
| `basic-server-preact/` | `server.ts`, `src/mcp-app.tsx` |
| `basic-server-solid/` | `server.ts`, `src/mcp-app.tsx` |

### API Reference (Source Files)

Read JSDoc documentation directly from `/tmp/mcp-ext-apps/src/`:

| File | Contents |
|------|----------|
| `src/app.ts` | `App` class, handlers, lifecycle |
| `src/server/index.ts` | `registerAppTool`, `registerAppResource`, visibility |
| `src/spec.types.ts` | All types: `McpUiHostContext`, CSS variables, display modes |
| `src/styles.ts` | `applyDocumentTheme`, `applyHostStyleVariables`, `applyHostFonts` |
| `src/react/useApp.tsx` | `useApp` hook for React apps |
| `src/react/useHostStyles.ts` | `useHostStyles`, `useHostStyleVariables`, `useHostFonts` |

### Advanced Examples

| Example | Pattern Demonstrated |
|---------|---------------------|
| `examples/shadertoy-server/` | Streaming partial input + visibility-based pause/play |
| `examples/wiki-explorer-server/` | `callServerTool` for interactive data fetching |
| `examples/system-monitor-server/` | Polling pattern with interval management |
| `examples/video-resource-server/` | Binary/blob resources |
| `examples/sheet-music-server/` | `ontoolinput` - processing args before execution |
| `examples/threejs-server/` | `ontoolinputpartial` - streaming/progressive rendering |
| `examples/map-server/` | `updateModelContext` - keeping model informed of UI state |
| `examples/transcript-server/` | `updateModelContext` + `sendMessage` - background updates + user messages |
| `examples/cohort-heatmap-server/` | Complex data visualization (heatmap grid) |
| `examples/scenario-modeler-server/` | Multi-parameter interactive modeling |
| `examples/budget-allocator-server/` | Form with interdependent calculated fields |
| `examples/customer-segmentation-server/` | Data filtering + visualization combo |
| `examples/pdf-server/` | Document rendering in iframe |
| `examples/qr-server/` | Python MCP server (non-TypeScript example) |
| `examples/say-server/` | Simple demo — minimal MCP App |
| `examples/quickstart/` | Official quickstart tutorial (start here) |
| `examples/basic-host/` | Reference host implementation using `AppBridge` |

## Critical Implementation Notes

### Adding Dependencies

```bash
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk express cors zod
npm install -D typescript tsx vite vite-plugin-singlefile @types/express @types/cors @types/node concurrently cross-env
```

### Server-Side Registration (Official Pattern — v1.0.1)

**ALWAYS use `registerAppTool()` and `registerAppResource()`** from `@modelcontextprotocol/ext-apps/server`. Do NOT manually register tools and resources separately — the helpers handle proper metadata linkage, MIME types, and resource registration.

```typescript
// server.ts
import {
  registerAppTool,
  registerAppResource,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import fs from "node:fs/promises";
import path from "node:path";

const DIST_DIR = path.join(import.meta.dirname, "dist");

export function createServer(): McpServer {
  const server = new McpServer({
    name: "My MCP App Server",
    version: "1.0.0",
  });

  const resourceUri = "ui://my-server/contact-grid.html";

  // Register tool WITH UI metadata
  registerAppTool(
    server,
    "view_contact_grid",
    {
      title: "Contact Grid",                              // Human-readable title
      description: "Display contact search results",
      inputSchema: { query: { type: "string" } },
      _meta: { ui: { resourceUri } },                     // Links tool → resource
    },
    async (args) => {
      const contacts = await fetchContacts(args.query);
      return {
        content: [{ type: "text", text: JSON.stringify(contacts) }],  // Text fallback
      };
    },
  );

  // Register resource that serves the bundled HTML
  registerAppResource(
    server,
    resourceUri,                                          // URI to match
    "contact-grid",                                       // Resource name
    { mimeType: RESOURCE_MIME_TYPE },                     // ALWAYS use this constant
    async () => {
      const html = await fs.readFile(
        path.join(DIST_DIR, "contact-grid.html"), "utf-8"
      );
      return {
        contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }],
      };
    },
  );

  return server;
}
```

### Server Entry Point (HTTP + Stdio)

Servers should support BOTH HTTP (for web/testing) and stdio (for Claude Desktop, VS Code):

```typescript
// main.ts
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import cors from "cors";
import express from "express";
import { createServer } from "./server.js";

async function main() {
  if (process.argv.includes("--stdio")) {
    // Stdio transport — for Claude Desktop, VS Code
    await createServer().connect(new StdioServerTransport());
  } else {
    // HTTP transport — for web hosts, testing
    const port = parseInt(process.env.PORT ?? "3001", 10);
    const app = express();
    app.use(cors());
    app.use(express.json());

    app.all("/mcp", async (req, res) => {
      const server = createServer();
      const transport = new StreamableHTTPServerTransport({
        sessionIdGenerator: undefined,
      });
      res.on("close", () => { transport.close(); server.close(); });
      await server.connect(transport);
      await transport.handleRequest(req, res, req.body);
    });

    app.listen(port, () => console.log(`MCP server: http://localhost:${port}/mcp`));
  }
}
main().catch(console.error);
```

### Package Scripts

```json
"scripts": {
  "build": "tsc --noEmit && vite build",
  "start": "concurrently 'vite build --watch' 'tsx watch main.ts'",
  "serve": "tsx main.ts",
  "stdio": "tsx main.ts --stdio"
}
```

### Handler Registration Order

Register ALL handlers BEFORE calling `app.connect()`:

```typescript
const app = new App({ name: "My App", version: "1.0.0" });
app.ontoolinput = (params) => { /* handle input */ };
app.ontoolresult = (result) => { /* handle result */ };
app.onhostcontextchanged = (ctx) => { /* handle context */ };
app.onteardown = async () => { return {}; };
await app.connect();
```

### Tool Visibility

```typescript
// Default: visible to both model and app
_meta: { ui: { resourceUri, visibility: ["model", "app"] } }

// UI-only (hidden from model) - for refresh, form submissions
_meta: { ui: { resourceUri, visibility: ["app"] } }

// Model-only (app cannot call)
_meta: { ui: { resourceUri, visibility: ["model"] } }
```

### Content Security Policy (CSP) & Permissions

If your app needs to load external resources (CDN scripts, map tiles, API endpoints) or access device capabilities (microphone, camera), declare them in `_meta.ui`:

```typescript
_meta: {
  ui: {
    resourceUri,
    // Allow loading from specific external origins
    csp: {
      "script-src": ["https://cdn.example.com"],
      "img-src": ["https://tiles.mapbox.com", "https://api.mapbox.com"],
      "connect-src": ["https://api.example.com"],
    },
    // Request device permissions (host will prompt user for consent)
    permissions: ["microphone", "camera"],
  },
}
```

**Default:** Apps run in a sandboxed iframe with NO external access. If you don't declare CSP, all external requests are blocked. Only declare what you actually need.

### Host Styling Integration

**React:**
```typescript
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
const { app } = useApp({ appInfo, capabilities, onAppCreated });
useHostStyles(app);
```

**CSS variables available after applying:**
```css
.container {
  background: var(--color-background-secondary);
  color: var(--color-text-primary);
  font-family: var(--font-sans);
  border-radius: var(--border-radius-md);
}
```

### Safe Area Handling

```typescript
app.onhostcontextchanged = (ctx) => {
  if (ctx.safeAreaInsets) {
    const { top, right, bottom, left } = ctx.safeAreaInsets;
    document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`;
  }
};
```

### Streaming Partial Input

For large tool inputs, use `ontoolinputpartial` to show progress:

```typescript
app.ontoolinputpartial = (params) => {
  const args = params.arguments; // Healed partial JSON - always valid
  preview.textContent = JSON.stringify(args, null, 2);
};
app.ontoolinput = (params) => {
  render(params.arguments); // Final complete input
};
```

### Visibility-Based Resource Management

Pause expensive operations when scrolled out of viewport:

```typescript
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) animation.play();
    else animation.pause();
  });
});
observer.observe(document.querySelector(".main"));
```

### Fullscreen Mode

```typescript
app.onhostcontextchanged = (ctx) => {
  if (ctx.availableDisplayModes?.includes("fullscreen")) {
    fullscreenBtn.style.display = "block";
  }
  if (ctx.displayMode) {
    container.classList.toggle("fullscreen", ctx.displayMode === "fullscreen");
  }
};

async function toggleFullscreen() {
  const result = await app.requestDisplayMode({ 
    mode: currentMode === "fullscreen" ? "inline" : "fullscreen" 
  });
  currentMode = result.mode;
}
```

## Common Mistakes to Avoid

1. **Dynamic JSON tree rendering** — Use direct component composition per app instead
2. **Replacing entire UI tree on every tool result** — Merge trees or use stable component structure
3. **Relying on `callServerTool` for basic interactivity** — Use client-side state first
4. **No capability detection** — Always check `app.getHostCapabilities()?.serverTools` before using `callServerTool`
5. **Handlers after connect()** — Register ALL handlers BEFORE `app.connect()`
6. **Missing single-file bundling** — Must use `vite-plugin-singlefile`
7. **Forgetting resource registration** — Both tool AND resource must be registered
8. **No text fallback** — Always provide `content` array for non-UI hosts
9. **Hardcoded styles** — Use host CSS variables for theme integration
10. **No streaming for large inputs** — Use `ontoolinputpartial` for progress
11. **No timeout on `callServerTool`** — Always wrap in `Promise.race` with 5s timeout; degrade to read-only on failure (see Graceful Degradation section)
12. **Sending `sendMessage` on every micro-edit** — Batch changes locally, submit once on explicit save action
13. **Inconsistent status colors across apps** — Use the shared `StatusBadge` with standard green/yellow/red/blue convention
14. **No dirty-state indicator on config UIs** — Users must know they have unsaved changes; use `useDirtyState` hook + `SaveBar` component

## Testing

### Using basic-host

```bash
# Terminal 1: Build and run your server
npm run build && npm run serve

# Terminal 2: Run basic-host
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
# Open http://localhost:8080
```

### Debug with sendLog

```typescript
await app.sendLog({ level: "info", data: "Debug message" });
await app.sendLog({ level: "error", data: { error: err.message } });
```

### Standalone Testing

Build and test each app as a standalone web page with hardcoded data before integrating with the MCP server. This catches rendering/interactivity bugs before the MCP lifecycle adds complexity:

```bash
# Add a dev mode that uses mock data
VITE_DEV_MODE=true npm run dev
```

```tsx
const data = import.meta.env.VITE_DEV_MODE 
  ? MOCK_DATA 
  : useToolResult();
```

---

## Real-World Examples from Production Apps

**Reference:** 11 GHL MCP Apps built in `/Users/jakeshore/.clawdbot/workspace/mcp-diagrams/ghl-mcp-apps-only/`

### 1. Contact Grid (Data Table)
**Tool:** `view_contact_grid`
**Use case:** Display contact search results in sortable grid
**Pattern:** Client-side sorting/filtering of initial dataset
**Components:** DataTable with column headers, row selection, status badges
**Data flow:** Send all contacts upfront → All interactions are local (React state)

```typescript
{
  name: 'view_contact_grid',
  description: 'Display contact search results in a data grid',
  inputSchema: {
    type: 'object',
    properties: {
      query: { type: 'string', description: 'Search query' },
      limit: { type: 'number', description: 'Max results (default: 25)' },
    },
  },
  _meta: {
    ui: { resourceUri: 'ui://ghl/contact-grid' },
  },
}
```

### 2. Pipeline Board (Kanban)
**Tool:** `view_pipeline_board`
**Use case:** Visual sales pipeline with drag-drop
**Pattern:** Hybrid — Client-side drag-drop + `updateModelContext` to track moves
**Components:** KanbanBoard, OpportunityCard, StageColumn
**Interactivity:** Drag opportunity → Update local state → Silently inform model via `updateModelContext`

```typescript
const onDragEnd = (opportunityId: string, newStageId: string) => {
  // Update local state immediately
  setOpportunities(prev => prev.map(opp =>
    opp.id === opportunityId ? { ...opp, stageId: newStageId } : opp
  ));
  
  // Inform model (no visible message)
  app?.updateModelContext({
    text: `User moved opportunity "${opp.name}" to stage "${newStage.name}"`
  });
};
```

### 3. Calendar View
**Tool:** `view_calendar`
**Use case:** Monthly appointment calendar
**Pattern:** Client-side navigation between months
**Components:** CalendarGrid, EventMarker, MonthNav
**State:** Month/year navigation is purely client-side

### 4. Opportunity Card (Detail View)
**Tool:** `view_opportunity_card`
**Use case:** Single opportunity detail card
**Pattern:** Static display (no interactivity needed)
**Components:** Card, LabeledField, StatusBadge, Timeline
**Data:** All details sent upfront

### 5. Invoice Preview
**Tool:** `view_invoice`
**Use case:** Invoice detail with line items
**Pattern:** Static display
**Components:** InvoiceHeader, LineItemTable, TotalsSummary
**Layout:** Fixed header + scrollable line items + sticky totals

### 6. Campaign Stats Dashboard
**Tool:** `show_campaign_stats`
**Use case:** Marketing campaign performance metrics
**Pattern:** Client-side stat calculations
**Components:** MetricCard, ProgressBar, TrendIndicator
**Calculations:** CTR, conversion rate, ROI calculated in UI from raw data

### 7. Agent Stats Dashboard
**Tool:** `show_agent_stats`
**Use case:** Agent performance leaderboard
**Pattern:** Client-side sorting/ranking
**Components:** LeaderboardTable, PerformanceBadge, RankIndicator
**Interactivity:** Sort by different metrics (calls, revenue, close rate)

### 8. Contact Timeline
**Tool:** `view_contact_timeline`
**Use case:** Activity feed for a contact
**Pattern:** Static display with expandable items
**Components:** TimelineItem, EventIcon, ExpandableDetails
**Layout:** Vertical timeline with timestamps

### 9. Workflow Status
**Tool:** `view_workflow_status`
**Use case:** Workflow execution progress
**Pattern:** Hybrid — Display current state + optional refresh
**Components:** WorkflowSteps, ProgressIndicator, StepStatus
**Refresh:** Optional `callServerTool` to re-fetch if host supports it

### 10. Quick Book (Appointment Booking)
**Tool:** `show_quick_book`
**Use case:** Embedded appointment booking form
**Pattern:** Hybrid — Form state local, submission via `sendMessage`
**Components:** DateTimePicker, ContactSelector, ServiceDropdown
**Flow:** User fills form → Click submit → `sendMessage` with booking details → Model executes booking

```typescript
const onSubmit = () => {
  app?.sendMessage({
    text: `Please book this appointment:\nContact: ${contact.name}\nDate: ${selectedDate}\nService: ${service}`
  });
};
```

### 11. Dashboard (Multi-Widget)
**Tool:** `view_dashboard`
**Use case:** Overview dashboard with multiple widgets
**Pattern:** Client-side layout with multiple components
**Components:** PageHeader, MetricCard, RecentActivity, QuickActions
**Layout:** Grid layout with responsive columns

### 12. Estimate Builder (Complex Form with Calculations)
**Tool:** `build_estimate`
**Use case:** Multi-line estimate/quote builder with live price calculations
**Pattern:** Client-side form state with derived calculations + `sendMessage` on submit
**Components:** FormGroup, LineItemEditor, CalculatedTotal, TaxSelector
**Interactivity:** Add/remove line items → recalculate subtotals, tax, total in real-time → Submit via model
**Key lesson:** All math runs client-side — server only needed at final submission

```typescript
const [lineItems, setLineItems] = useState<LineItem[]>([]);
const subtotal = useMemo(() => lineItems.reduce((sum, li) => sum + li.qty * li.price, 0), [lineItems]);
const tax = subtotal * taxRate;
const total = subtotal + tax;
// Submit only when user clicks "Send Estimate"
const onSubmit = () => app?.sendMessage({ text: `Create estimate:\n${JSON.stringify({ lineItems, total })}` });
```

### 13. Duplicate Checker (Comparison UI with Merge Actions)
**Tool:** `check_duplicates`
**Use case:** Side-by-side comparison of potential duplicate records with merge/dismiss
**Pattern:** Client-side pair navigation + `sendMessage` for merge action
**Components:** ComparisonCard, FieldDiffHighlight, MergeSelector, DismissButton
**Interactivity:** Navigate pairs locally → Select winning fields per row → Submit merge decision via model
**Key lesson:** Highlight field-level differences with color coding (green = match, yellow = conflict, red = missing)

### 14. Media Library (Async Asset Grid)
**Tool:** `view_media_library`
**Use case:** Browsable grid of uploaded images/files with preview
**Pattern:** Client-side grid with lazy thumbnail loading + `callServerTool` for pagination
**Components:** AssetGrid, ThumbnailCard, PreviewModal, FilterBar, UploadDropzone
**Interactivity:** Click thumbnail → expand preview modal (local) | Load more → `callServerTool` pagination
**Key lesson:** Use `loading="lazy"` on `<img>` tags and intersection observer for progressive loading — don't load 200 thumbnails at once

```typescript
const loadPage = async (page: number) => {
  if (!canCallTools) return; // Degrade: show "load more in chat" button
  const result = await withTimeout(app.callServerTool({
    name: "list_media", arguments: { offset: page * 50, limit: 50 }
  }), 5000);
  setAssets(prev => [...prev, ...result.assets]);
};
```

### 15. Inventory Dashboard (Multi-Widget Composition)
**Tool:** `view_inventory`
**Use case:** Stock levels, low-stock alerts, category breakdown in one view
**Pattern:** Client-side widget composition with independent state per widget
**Components:** StockLevelGauge, LowStockAlert, CategoryBreakdownChart, ReorderQueue
**Layout:** CSS Grid with responsive breakpoints — 3 columns on desktop, 1 on mobile
**Key lesson:** Each widget manages its own state independently; parent only provides data. No cross-widget state coupling.

### 16. Conversation Thread (Chat-Style Feed)
**Tool:** `view_conversation`
**Use case:** Message history displayed as chat bubbles with sender alignment
**Pattern:** Static display with scroll-to-bottom + optional `callServerTool` for older messages
**Components:** MessageBubble, SenderAvatar, TimestampDivider, AttachmentPreview
**Layout:** Flex column with `flex-direction: column-reverse` for natural scroll behavior
**Key lesson:** Distinguish inbound vs outbound messages via alignment (left/right) and color, not just labels

### 17. Free Slots Finder (Interactive Scheduling)
**Tool:** `find_free_slots`
**Use case:** Display available time slots for booking, filterable by date/duration
**Pattern:** Client-side date navigation + slot selection + `sendMessage` to book
**Components:** DateStrip, SlotGrid, DurationFilter, SelectedSlotSummary
**Interactivity:** Swipe dates (local) → Filter by duration (local) → Select slot → "Book This" sends to model
**Key lesson:** Send a full week of slots upfront to enable instant date switching without server calls

### 18. Custom Fields Manager (Configuration/Settings UI)
**Tool:** `manage_custom_fields`
**Use case:** CRUD interface for custom field definitions (add, reorder, edit types)
**Pattern:** Client-side list management with drag-to-reorder + batch `sendMessage` save
**Components:** FieldRow, TypeSelector, DragHandle, AddFieldButton, SaveBar
**Interactivity:** All adds/edits/reorders happen locally → Sticky save bar appears with change count → Submit all changes at once
**Key lesson:** Track a `dirty` flag and show unsaved changes indicator — users need to know they have pending edits

```typescript
const [fields, setFields] = useState(data.fields);
const [originalFields] = useState(data.fields);
const isDirty = JSON.stringify(fields) !== JSON.stringify(originalFields);
// Sticky save bar only appears when isDirty
```

### 19. Pipeline Analytics (Visualization Dashboard)
**Tool:** `view_pipeline_analytics`
**Use case:** Funnel visualization, conversion rates, stage duration metrics
**Pattern:** Client-side chart rendering with time-range selector
**Components:** FunnelChart, ConversionRateCard, StageDurationBar, TimeRangeSelector
**Interactivity:** Switch time ranges locally (7d/30d/90d) → Charts recalculate from full dataset
**Key lesson:** Send raw data for all time ranges upfront, let the UI slice — avoids server roundtrips for every filter change

---

## Common Patterns from Real Apps (65 Production Apps)

### Pattern: Static Display (No Interactivity)
**Use for:** Detail views, invoices, timelines
**Apps:** Opportunity Card, Invoice Preview, Contact Timeline
**Approach:** Send all data upfront, render with zero client logic

### Pattern: Client-Side Interactivity
**Use for:** Sorting, filtering, navigation, local forms
**Apps:** Contact Grid, Calendar View, Agent Stats
**Approach:** All interactions via React state, no server calls

### Pattern: Drag-Drop with Silent Sync
**Use for:** Kanban boards, reordering
**Apps:** Pipeline Board
**Approach:** Update local state + `updateModelContext` (no visible message)

### Pattern: Form + Submit via Model
**Use for:** Booking, creating records
**Apps:** Quick Book
**Approach:** Local form state, `sendMessage` on submit, model handles server write

### Pattern: Dashboard with Multiple Widgets
**Use for:** Overview screens, analytics
**Apps:** Dashboard, Campaign Stats, Inventory Dashboard, Revenue Dashboard, Location Dashboard
**Approach:** Grid layout, each widget self-contained, calculations client-side

### Pattern: Complex Form Builder
**Use for:** Creating/editing multi-field records with calculations
**Apps:** Estimate Builder, Invoice Builder, Contact Creator, Message Composer, Social Post Composer
**Approach:** All form state + derived calculations are client-side; `sendMessage` only on final submit. Show live totals/previews as user edits.

### Pattern: Comparison / Deduplication
**Use for:** Side-by-side record comparison, merge decisions
**Apps:** Duplicate Checker
**Approach:** Present pairs with field-level diff highlighting. User selects winning values locally, submits merge decision via model.

### Pattern: Asset Grid / Gallery
**Use for:** Browsable collections of images, files, templates
**Apps:** Media Library, Template Library
**Approach:** Lazy-loading grid with thumbnail cards. Preview modal on click (local). Pagination via `callServerTool` as progressive enhancement.

### Pattern: Master → Detail Navigation
**Use for:** List views that link to detail views
**Apps:** Company List → Company Detail, Product Catalog → Product Detail, Funnel List → Funnel Detail, Course Catalog → Course Detail, Order List → Order Detail, Invoice List → Invoice Preview
**Approach:** List view sends all summary data upfront; clicking an item either expands inline (local state) or triggers a new tool call for the detail view via `sendMessage`.

### Pattern: Analytics / Visualization
**Use for:** Charts, funnels, conversion tracking
**Apps:** Pipeline Analytics, Pipeline Funnel, Revenue Dashboard, Reviews Dashboard
**Approach:** Send raw data for all time ranges/filters upfront. All chart rendering, time-range switching, and metric calculations happen client-side. Avoid server calls for filter changes.

### Pattern: Configuration / Settings
**Use for:** Managing field definitions, tags, accounts, team members
**Apps:** Custom Fields Manager, Tags Manager, Social Accounts, Subscription Manager, Team Management
**Approach:** Local CRUD with dirty-state tracking. Sticky save bar with change count. Batch submit all changes via single `sendMessage`.

### Pattern: Feed / Conversation
**Use for:** Chat history, activity logs, notification streams
**Apps:** Conversation List, Conversation Thread, Message Detail, Call Log
**Approach:** Chronological display with sender differentiation. Use `flex-direction: column-reverse` for auto-scroll-to-bottom. Lazy-load older messages via `callServerTool` if supported.

### Pattern: Interactive Scheduling
**Use for:** Time slot selection, calendar-based booking
**Apps:** Free Slots Finder, Calendar Resources, Social Calendar, Appointment Booker
**Approach:** Send a full week/month of slots upfront for instant navigation. Date/duration filtering is local. Selection triggers `sendMessage` to book via model.

---

## Lessons Learned from 65 Production Apps

### 1. Send All Data Upfront When Possible
**Why:** Avoids host compatibility issues, works everywhere
**Pattern:** Contact Grid sends all 25 results → All sorting/filtering is local
**Extended:** Estimate Builder sends tax rates, product list, and customer info upfront — all calculations are instant

### 2. Use `updateModelContext` for Silent Tracking
**Why:** Keeps model informed without cluttering chat
**Pattern:** Pipeline Board silently tracks every drag-drop move
**Extended:** Custom Fields Manager tracks all add/edit/reorder/delete actions silently until explicit save

### 3. Reserve `sendMessage` for Explicit Actions
**Why:** Visible messages should be intentional user requests
**Pattern:** Quick Book only sends message when user clicks "Book Appointment"
**Extended:** Estimate Builder, Invoice Builder, Contact Creator all follow this — form state is local, submission is explicit

### 4. Static Views Are Valid (Not Everything Needs Buttons)
**Why:** Sometimes you just need to display data beautifully
**Pattern:** Invoice Preview, Opportunity Card are pure display
**Extended:** Call Detail, Order Detail, User Detail, Estimate Preview — roughly 20% of all 65 apps are pure static display

### 5. Avoid Premature `callServerTool` Optimization
**Why:** Not all hosts support it, adds complexity
**Pattern:** Build client-side first, layer on `callServerTool` for refresh/pagination only if needed
**Extended:** Only ~5 of 65 apps actually need `callServerTool` (Media Library pagination, Conversation Thread history loading). The other 60 work perfectly with upfront data.

### 6. Shared Component Library = Consistency Win
**Why:** Reusable UI components across all 65 apps
**Components:** See **Shared Component Catalog** section below
**Location:** Shared `components/` directory imported by all apps

### 7. Inline HTML Works Great for Simple Apps
**Why:** For apps under 200 lines, skip React and use vanilla HTML
**Pattern:** Several GHL apps use inline HTML with minimal JavaScript
**Benefits:** Zero build step, instant preview, easy to debug

### 8. Track Dirty State for Settings/Config UIs
**Why:** Users need to know they have unsaved changes
**Pattern:** Custom Fields Manager, Tags Manager show a sticky save bar with change count when edits are pending
**Implementation:** Compare current state to original snapshot; show "X unsaved changes" indicator

### 9. Batch Changes, Don't Spam the Model
**Why:** Sending a `sendMessage` for every micro-edit floods the conversation
**Pattern:** Custom Fields Manager, Team Management collect all changes locally → single batch submit
**Anti-pattern:** ╳ Sending `sendMessage` on every field edit, every drag, every toggle

### 10. Master-Detail Can Be One App or Two
**Why:** Some detail views are complex enough to warrant separate apps
**Decision:** If detail view is <100 lines → expand inline (Company List with accordion). If detail view is >100 lines or has its own interactivity → separate app (Invoice List → Invoice Preview)
**Pattern:** 6 master-detail pairs in the 65 apps, split roughly 50/50 between inline and separate

### 11. Pre-Calculate ALL Time Ranges
**Why:** Users expect instant filter switching; server roundtrips feel broken
**Pattern:** Pipeline Analytics, Revenue Dashboard send raw data for 7d/30d/90d/all → UI slices and re-renders charts locally
**Anti-pattern:** ╳ Calling `callServerTool` every time user switches from "7 days" to "30 days"

### 12. Color-Code Status Consistently Across Apps
**Why:** Users build muscle memory for what green/yellow/red mean
**Convention used across 65 apps:**
- Green: active, complete, paid, healthy
- Yellow/amber: pending, in-progress, due soon
- Red: overdue, failed, critical, inactive
- Blue: informational, new, neutral

---

## Shared Component Catalog

Reusable components available across all apps. Import from `../components/` when building new apps.

### Layout Components (`components/layout/`)
| Component | Purpose | Used In |
|-----------|---------|---------|
| `PageHeader` | Title bar with optional subtitle, actions, breadcrumbs | Nearly all apps |
| `Card` | Bordered container with optional header/footer | Detail views, dashboard widgets |
| `SplitLayout` | Two-panel side-by-side layout (list + detail) | Duplicate Checker, Master-Detail pairs |
| `StatsGrid` | Responsive grid for metric cards (auto 1-4 columns) | All dashboard/analytics apps |
| `Section` | Collapsible section with header | Settings UIs, long forms |
| `StickyFooter` | Fixed bottom bar for save/submit actions | Form builders, config UIs |

### Data Components (`components/data/`)
| Component | Purpose | Used In |
|-----------|---------|---------|
| `DataTable` | Sortable, filterable table with column headers | Contact Grid, Invoice List, Order List, Transaction List |
| `KanbanBoard` | Drag-drop column board | Pipeline Kanban, Task Board |
| `MetricCard` | Single stat with label, value, trend indicator | All dashboards |
| `Timeline` | Vertical chronological event list | Contact Timeline, Workflow Status |
| `StatusBadge` | Colored pill badge (green/yellow/red/blue) | Everywhere — status display |
| `LeaderboardTable` | Ranked table with position indicators | Agent Stats |
| `ComparisonCard` | Side-by-side field comparison with diff highlighting | Duplicate Checker |
| `FieldDiffHighlight` | Color-coded field match/conflict/missing indicator | Duplicate Checker |

### Chart Components (`components/charts/`)
| Component | Purpose | Used In |
|-----------|---------|---------|
| `BarChart` | Horizontal/vertical bar chart | Campaign Stats, Agent Stats |
| `LineChart` | Time-series line chart | Revenue Dashboard, Pipeline Analytics |
| `PieChart` | Pie/donut chart | Inventory Dashboard, Category breakdowns |
| `FunnelChart` | Conversion funnel visualization | Pipeline Funnel, Pipeline Analytics |
| `ProgressBar` | Horizontal progress indicator | Campaign Stats, Workflow Status |
| `TrendIndicator` | Up/down arrow with percentage change | MetricCard companion |
| `StockLevelGauge` | Fill-level indicator (0-100%) | Inventory Dashboard |

### Interactive Components (`components/interactive/`)
| Component | Purpose | Used In |
|-----------|---------|---------|
| `ContactPicker` | Searchable contact selector dropdown | Quick Book, Message Composer |
| `InvoiceBuilder` | Line item editor with add/remove/reorder | Invoice Builder, Estimate Builder |
| `FormGroup` | Label + input + validation error display | All form apps |
| `DateTimePicker` | Date and time selection | Quick Book, Free Slots Finder |
| `DurationFilter` | Duration range selector (15min/30min/1hr) | Free Slots Finder |
| `DateStrip` | Horizontal scrollable date selector | Free Slots Finder, Social Calendar |
| `SlotGrid` | Time slot grid with selection state | Free Slots Finder, Calendar Resources |
| `LineItemEditor` | Add/remove/edit rows with calculated totals | Estimate Builder, Invoice Builder |
| `TypeSelector` | Dropdown for field type selection | Custom Fields Manager |
| `DragHandle` | Drag affordance for reorderable lists | Custom Fields Manager, Task Board |

### Shared Components (`components/shared/`)
| Component | Purpose | Used In |
|-----------|---------|---------|
| `ActionButton` | Primary/secondary/danger button variants | All interactive apps |
| `SearchBar` | Debounced search input with clear button | Contact Grid, Media Library, Smartlist Viewer |
| `Toast` | Temporary notification popup | Form submissions, error feedback |
| `Modal` | Overlay dialog with backdrop | Media Library preview, confirmation dialogs |
| `EmptyState` | Illustration + message when no data | All list/grid apps |
| `LoadingSpinner` | Consistent loading indicator | Apps using `callServerTool` |
| `SaveBar` | Sticky bar showing "X unsaved changes" + Save/Discard | Config UIs (Custom Fields, Tags Manager) |
| `FilterBar` | Horizontal filter chips/dropdowns | Media Library, Smartlist Viewer, List apps |
| `ThumbnailCard` | Image card with overlay info | Media Library, Template Library |
| `MessageBubble` | Chat-style message with sender alignment | Conversation Thread |
| `TimestampDivider` | "Today" / "Yesterday" divider in feeds | Conversation Thread, Call Log |

### Hooks (`hooks/`)
| Hook | Purpose |
|------|---------|
| `useCallTool` | Wrapper for `callServerTool` with loading/error state |
| `useSmartAction` | Capability-detected action dispatch (direct vs fallback) |
| `useHostCapabilities` | Read host capabilities once on mount |
| `useDirtyState` | Track original vs current state, expose `isDirty` and `changeCount` |
| `useDebounce` | Debounce value changes (search input, auto-save) |
| `useLazyLoad` | Intersection observer for lazy loading grid items |

---

## Graceful Degradation & Timeout Strategy

### `callServerTool` Timeout (MANDATORY)

If `ui/initialize` never completes or `callServerTool` hangs, apps must degrade gracefully within 5 seconds — not spin forever.

```typescript
// hooks/useCallTool.ts
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  return Promise.race([
    promise,
    new Promise<never>((_, reject) =>
      setTimeout(() => reject(new Error(`callServerTool timed out after ${ms}ms`)), ms)
    ),
  ]);
}

function useCallTool() {
  const { app } = useMCPApp();
  const canCallTools = !!app?.getHostCapabilities()?.serverTools;

  const callTool = async (name: string, args: Record<string, unknown>) => {
    if (!canCallTools) return { ok: false, reason: 'unsupported' as const };
    try {
      const result = await withTimeout(
        app!.callServerTool({ name, arguments: args }),
        5000 // 5 second hard timeout
      );
      return { ok: true, data: result };
    } catch (err) {
      return { ok: false, reason: 'timeout' as const, error: err };
    }
  };

  return { callTool, canCallTools };
}
```

### Degradation Tiers

| Tier | Condition | Behavior |
|------|-----------|----------|
| Full | Host supports `callServerTool` + responds in <5s | All features enabled |
| Read-Only | `callServerTool` times out or errors | Display data from initial `ontoolresult` only; disable pagination/refresh; show "Data loaded at [time]" |
| Fallback | Host doesn't support `callServerTool` at all | Same as Read-Only; show "Load more in chat" button that uses `sendMessage` |
| Text-Only | Host doesn't render UI | Return `content` array with formatted text (ALWAYS provide this) |

### Init Handshake Timeout

If `ui/initialize` hasn't completed within 3 seconds, assume limited host and proceed in read-only mode:

```typescript
const [initComplete, setInitComplete] = useState(false);
const [timedOut, setTimedOut] = useState(false);

useEffect(() => {
  const timer = setTimeout(() => {
    if (!initComplete) setTimedOut(true);
  }, 3000);
  return () => clearTimeout(timer);
}, [initComplete]);

// In render:
if (timedOut && !initComplete) {
  return <ReadOnlyView data={data} notice="Running in read-only mode" />;
}
```

### Text Fallback (ALWAYS required)

Every tool MUST return a `content` array with meaningful text, even when a UI resource exists. Non-UI hosts (CLI tools, API consumers) only see this:

```typescript
return {
  content: [
    { type: 'text', text: `Found ${contacts.length} contacts matching "${query}":\n${contacts.map(c => `- ${c.name} (${c.email})`).join('\n')}` }
  ],
  _meta: { ui: { resourceUri: 'ui://ghl/contact-grid' } },
};
```

---

## Reference Implementations

**Full source code (65 apps):**
- `/Users/jakeshore/.clawdbot/workspace/mcp-diagrams/GoHighLevel-MCP/src/ui/react-app/src/apps/`
- 65 complete apps across 14 pattern categories
- Shared component library (`components/`)
- Shared hooks library (`hooks/`)
- Build scripts for copying HTML to dist/

**Standalone app reference (11 apps with structuredContent):**
- `/Users/jakeshore/.clawdbot/workspace/mcp-diagrams/ghl-mcp-apps-only/`

**Server integration:**
- `/Users/jakeshore/.clawdbot/workspace/mcp-diagrams/GoHighLevel-MCP/src/apps/index.ts`
- MCPAppsManager class pattern
- Resource handler registration
- Tool handler implementation

Use these as templates when building new MCP Apps.
