🥞PancakeJS

Building Interactive Widgets

Widgets transform chat apps from simple text interfaces into rich, interactive experiences. This guide walks you through building production-ready widgets, from project structure to deployment.

Widget Anatomy

Every widget has two parts that work together:

┌─────────────────────────────────────────────────────────────────┐
│                         Your Widget                              │
│                                                                  │
│   ┌─────────────────────────┐    ┌─────────────────────────┐   │
│   │     Server-Side         │    │     Client-Side          │   │
│   │                         │    │                          │   │
│   │  • Tool definition      │───►│  • React component       │   │
│   │  • Input validation     │    │  • Event handlers        │   │
│   │  • Data fetching        │    │  • Host communication    │   │
│   │  • Widget rendering     │    │  • State management      │   │
│   │                         │    │                          │   │
│   │   server.ts             │    │   widgets/my-widget.tsx  │   │
│   └─────────────────────────┘    └─────────────────────────┘   │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Setting Up Your Widget

Create the Server Handler

Define your widget tool in your server file. This specifies what the AI sees and how data flows:

// server.ts
import { z } from 'zod';
import { createUniversalServerApp, renderWidget } from '@pancakeapps/server';

const app = createUniversalServerApp({
  name: 'restaurant-finder',
  version: '1.0.0',
});

app.widget({
  name: 'restaurant-search',
  description: 'Search for restaurants by cuisine, location, and price range',
  inputSchema: z.object({
    cuisine: z.string().describe('Type of cuisine (e.g., Italian, Japanese)'),
    location: z.string().describe('City or neighborhood'),
    priceRange: z.enum(['$', '$$', '$$$', '$$$$']).optional(),
  }),
  ui: {
    entry: 'src/widgets/restaurant-search.tsx',
    csp: {
      connectDomains: ['api.restaurants.example.com'],
      imgDomains: ['images.restaurants.example.com'],
    },
  },
}, async (input) => {
  // Fetch initial data
  const restaurants = await searchRestaurants(input);
  
  // Return widget with data
  return renderWidget('restaurant-search', {
    content: [{ 
      type: 'text', 
      text: `Found ${restaurants.length} restaurants` 
    }],
    data: {
      restaurants,
      searchParams: input,
    },
  });
});

Create the Widget Component

Build your React component that renders in the chat:

// src/widgets/restaurant-search.tsx
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
  UniversalAppProvider,
  useToolInvocation,
  useCallTool,
  useHost,
} from '@pancakeapps/react';

function RestaurantSearchWidget() {
  const { data, input } = useToolInvocation();
  const [selectedRestaurant, setSelectedRestaurant] = useState(null);
  const host = useHost();
  
  const makeReservation = useCallTool('make_reservation');
  
  return (
    <div className={`widget ${host.context.theme}`}>
      <header>
        <h2>{input.cuisine} in {input.location}</h2>
        <span>{data.restaurants.length} results</span>
      </header>
      
      <div className="restaurant-grid">
        {data.restaurants.map(restaurant => (
          <RestaurantCard
            key={restaurant.id}
            restaurant={restaurant}
            onSelect={() => setSelectedRestaurant(restaurant)}
          />
        ))}
      </div>
      
      {selectedRestaurant && (
        <ReservationModal
          restaurant={selectedRestaurant}
          onBook={(details) => makeReservation({
            restaurantId: selectedRestaurant.id,
            ...details,
          })}
          onClose={() => setSelectedRestaurant(null)}
        />
      )}
    </div>
  );
}

// Root component with provider
function App() {
  return (
    <UniversalAppProvider>
      <RestaurantSearchWidget />
    </UniversalAppProvider>
  );
}

// Mount the app
createRoot(document.getElementById('root')!).render(<App />);

Add Styling

Create styles that work well in the chat context:

/* src/widgets/restaurant-search.css */
.widget {
  font-family: system-ui, -apple-system, sans-serif;
  padding: 1rem;
  border-radius: 12px;
}

.widget.dark {
  background: #1a1a1a;
  color: #fafafa;
}

.widget.light {
  background: #ffffff;
  color: #171717;
}

.restaurant-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 1rem;
  margin-top: 1rem;
}

The UniversalAppProvider

Every widget must be wrapped with UniversalAppProvider. This component:

  • Establishes the connection with the host
  • Provides React context for all PancakeJS hooks
  • Handles platform-specific communication
  • Manages the widget lifecycle
import { UniversalAppProvider } from '@pancakeapps/react';

function App() {
  return (
    <UniversalAppProvider
      onError={(error) => console.error('Widget error:', error)}
      fallback={<LoadingState />}
    >
      <YourWidget />
    </UniversalAppProvider>
  );
}

Never use PancakeJS hooks (useToolInvocation, useCallTool, etc.) outside of the UniversalAppProvider tree. They depend on context it provides.

Essential Hooks Reference

useToolInvocation

Access the data passed from your tool handler:

const {
  data,       // Structured data from renderWidget()
  input,      // Parameters the AI passed to your tool
  meta,       // Metadata about the invocation
  isLoading,  // True until data is received
  error,      // Error if tool invocation failed
} = useToolInvocation();

useCallTool

Call other tools from your widget:

const bookFlight = useCallTool('book_flight');

// Returns a promise with the tool result
const result = await bookFlight({
  flightId: 'FL123',
  passengers: 2,
});

useWidgetState

Persist state across re-renders:

// Similar to useState, but persists in the host
const [tab, setTab] = useWidgetState('currentTab', 'overview');
const [filters, setFilters] = useWidgetState('filters', {});

useHost

Access host context and capabilities:

const host = useHost();

// Context
host.context.theme          // 'light' | 'dark'
host.context.displayMode    // 'inline' | 'fullscreen' | 'pip'
host.context.viewport       // { width, height }

// Methods
host.requestDisplayMode('fullscreen');
host.openExternal({ href: 'https://...' });

useSendMessage

Send messages back to the AI:

const sendMessage = useSendMessage();

await sendMessage({
  prompt: 'Now help me find hotels near this restaurant',
});

useCapabilities

Check what the current host supports:

const caps = useCapabilities();

if (caps.canPersistState) {
  // Safe to use useWidgetState
}
if (caps.canRequestFullscreen) {
  // Show fullscreen button
}

Handling User Interactions

Click Actions with Tool Calls

function ProductCard({ product }) {
  const addToCart = useCallTool('add_to_cart');
  const [adding, setAdding] = useState(false);
  
  const handleAdd = async () => {
    setAdding(true);
    try {
      await addToCart({ productId: product.id, quantity: 1 });
      // Show success feedback
    } catch (error) {
      // Show error feedback
    } finally {
      setAdding(false);
    }
  };
  
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={handleAdd} disabled={adding}>
        {adding ? 'Adding...' : 'Add to Cart'}
      </button>
    </div>
  );
}

Forms and Input

function SearchFilters() {
  const [filters, setFilters] = useWidgetState('filters', {
    minPrice: 0,
    maxPrice: 1000,
    category: 'all',
  });
  
  const applyFilters = useCallTool('filter_results');
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    await applyFilters(filters);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <label>
        Category
        <select 
          value={filters.category}
          onChange={(e) => setFilters({...filters, category: e.target.value})}
        >
          <option value="all">All</option>
          <option value="electronics">Electronics</option>
          <option value="clothing">Clothing</option>
        </select>
      </label>
      
      <label>
        Price Range
        <input
          type="range"
          min="0"
          max="1000"
          value={filters.maxPrice}
          onChange={(e) => setFilters({...filters, maxPrice: +e.target.value})}
        />
      </label>
      
      <button type="submit">Apply Filters</button>
    </form>
  );
}

Continuing the Conversation

function TripSummary({ trip }) {
  const sendMessage = useSendMessage();
  
  const suggestNext = async (suggestion) => {
    const prompts = {
      hotels: `Find hotels near ${trip.destination} for ${trip.dates}`,
      activities: `What are fun activities in ${trip.destination}?`,
      restaurants: `Recommend restaurants in ${trip.destination}`,
    };
    
    await sendMessage({ prompt: prompts[suggestion] });
  };
  
  return (
    <div className="trip-summary">
      <h2>Trip to {trip.destination}</h2>
      <FlightDetails flight={trip.flight} />
      
      <div className="next-steps">
        <p>What would you like to do next?</p>
        <button onClick={() => suggestNext('hotels')}>Find Hotels</button>
        <button onClick={() => suggestNext('activities')}>Find Activities</button>
        <button onClick={() => suggestNext('restaurants')}>Find Restaurants</button>
      </div>
    </div>
  );
}

Styling Best Practices

Respecting Host Themes

Always support both light and dark themes:

function ThemedWidget() {
  const { theme } = useHost().context;
  
  return (
    <div
      style={{
        background: theme === 'dark' ? '#1a1a1a' : '#ffffff',
        color: theme === 'dark' ? '#fafafa' : '#171717',
        // Use CSS custom properties for consistency
        '--accent': theme === 'dark' ? '#60a5fa' : '#2563eb',
      }}
    >
      <Content />
    </div>
  );
}

Or use CSS classes:

.widget {
  --bg: #ffffff;
  --fg: #171717;
  --accent: #2563eb;
}

.widget.dark {
  --bg: #1a1a1a;
  --fg: #fafafa;
  --accent: #60a5fa;
}

.widget {
  background: var(--bg);
  color: var(--fg);
}

.widget button {
  background: var(--accent);
  color: white;
}

Responsive Design

Widgets appear in various viewport sizes. Design accordingly:

.widget-grid {
  display: grid;
  gap: 1rem;
  
  /* Single column on small viewports */
  grid-template-columns: 1fr;
}

@media (min-width: 400px) {
  .widget-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (min-width: 600px) {
  .widget-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

Performance Considerations

// Use CSS for animations, not JavaScript
.fade-in {
  animation: fadeIn 200ms ease-out;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}

// Avoid large images - use appropriate sizes
<img 
  src={getOptimizedUrl(image, { width: 200, height: 150 })}
  loading="lazy"
  alt={alt}
/>

Error Handling

Build resilient widgets that handle failures gracefully:

function RobustWidget() {
  const { data, isLoading, error } = useToolInvocation();
  
  if (isLoading) {
    return (
      <div className="loading">
        <Spinner />
        <p>Loading your results...</p>
      </div>
    );
  }
  
  if (error) {
    return (
      <div className="error">
        <h3>Something went wrong</h3>
        <p>{error.message}</p>
        <button onClick={() => window.location.reload()}>
          Try Again
        </button>
      </div>
    );
  }
  
  return <MainContent data={data} />;
}

Error Boundaries

Catch React errors to prevent widget crashes:

import { ErrorBoundary } from '@pancakeapps/react';

function App() {
  return (
    <UniversalAppProvider>
      <ErrorBoundary
        fallback={({ error, resetError }) => (
          <div className="error-boundary">
            <p>Widget crashed: {error.message}</p>
            <button onClick={resetError}>Reset</button>
          </div>
        )}
      >
        <MyWidget />
      </ErrorBoundary>
    </UniversalAppProvider>
  );
}

Testing Your Widget

Local Development

Use the PancakeJS inspector for local testing:

pnpm universal-apps dev

Then open http://localhost:3000/_inspector to:

  • Test tool invocations with different inputs
  • Preview widget rendering
  • Simulate both MCP and ChatGPT hosts
  • View communication logs

Testing with Real Hosts

  1. Expose your local server with a tool like ngrok:

    ngrok http 3000
  2. Create a Custom GPT in ChatGPT with your ngrok URL

  3. Test your widget in actual ChatGPT conversations

  1. Add your MCP server to Claude Desktop config:

    {
      "mcpServers": {
        "my-app": {
          "command": "node",
          "args": ["path/to/your/server.js"]
        }
      }
    }
  2. Restart Claude Desktop

  3. Test your tools and widgets

Complete Example

Here's a complete, production-ready widget:

// server.ts
import { z } from 'zod';
import { createUniversalServerApp, renderWidget } from '@pancakeapps/server';
import { createMcpAppsServerAdapter } from '@pancakeapps/adapter-mcp-apps';
import { createChatGptServerAdapter } from '@pancakeapps/adapter-chatgpt-apps';

const app = createUniversalServerApp({
  name: 'task-board',
  version: '1.0.0',
});

// Widget for displaying and managing tasks
app.widget({
  name: 'kanban-board',
  description: 'Display and manage tasks in a Kanban board',
  inputSchema: z.object({
    projectId: z.string().describe('Project ID to load tasks for'),
  }),
  ui: {
    entry: 'src/widgets/kanban.tsx',
  },
}, async (input) => {
  const project = await loadProject(input.projectId);
  const columns = await loadColumns(input.projectId);
  
  return renderWidget('kanban-board', {
    data: { project, columns },
  });
});

// Tool for moving tasks (called by widget)
app.tool({
  name: 'move_task',
  description: 'Move a task to a different column',
  inputSchema: z.object({
    taskId: z.string(),
    columnId: z.string(),
    position: z.number(),
  }),
}, async (input) => {
  const task = await moveTask(input);
  return { success: true, task };
});

app.adapter(createMcpAppsServerAdapter());
app.adapter(createChatGptServerAdapter());
app.listen({ port: 3000, dev: true });
// src/widgets/kanban.tsx
import React, { useState, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
import {
  UniversalAppProvider,
  useToolInvocation,
  useCallTool,
  useWidgetState,
  useHost,
} from '@pancakeapps/react';
import './kanban.css';

function KanbanBoard() {
  const { data } = useToolInvocation();
  const [columns, setColumns] = useWidgetState('columns', data.columns);
  const moveTask = useCallTool('move_task');
  const host = useHost();

  const handleDrop = useCallback(async (taskId, columnId, position) => {
    // Optimistic update
    const newColumns = moveTaskLocally(columns, taskId, columnId, position);
    setColumns(newColumns);
    
    try {
      await moveTask({ taskId, columnId, position });
    } catch (error) {
      // Revert on failure
      setColumns(columns);
    }
  }, [columns, setColumns, moveTask]);

  return (
    <div className={`kanban ${host.context.theme}`}>
      <header>
        <h2>{data.project.name}</h2>
      </header>
      
      <div className="columns">
        {columns.map(column => (
          <Column
            key={column.id}
            column={column}
            onDrop={(taskId, position) => 
              handleDrop(taskId, column.id, position)
            }
          />
        ))}
      </div>
    </div>
  );
}

function Column({ column, onDrop }) {
  return (
    <div className="column">
      <h3>{column.title}</h3>
      <div className="tasks">
        {column.tasks.map((task, index) => (
          <TaskCard 
            key={task.id} 
            task={task}
            onDrop={(taskId) => onDrop(taskId, index)}
          />
        ))}
      </div>
    </div>
  );
}

function TaskCard({ task, onDrop }) {
  const [isDragging, setIsDragging] = useState(false);
  
  return (
    <div 
      className={`task ${isDragging ? 'dragging' : ''}`}
      draggable
      onDragStart={(e) => {
        e.dataTransfer.setData('taskId', task.id);
        setIsDragging(true);
      }}
      onDragEnd={() => setIsDragging(false)}
      onDragOver={(e) => e.preventDefault()}
      onDrop={(e) => {
        const taskId = e.dataTransfer.getData('taskId');
        if (taskId !== task.id) {
          onDrop(taskId);
        }
      }}
    >
      <span className={`priority ${task.priority}`} />
      <p>{task.title}</p>
    </div>
  );
}

function App() {
  return (
    <UniversalAppProvider>
      <KanbanBoard />
    </UniversalAppProvider>
  );
}

createRoot(document.getElementById('root')!).render(<App />);
/* src/widgets/kanban.css */
.kanban {
  --bg: #ffffff;
  --card-bg: #f5f5f5;
  --text: #171717;
  --border: #e5e5e5;
  
  font-family: system-ui, -apple-system, sans-serif;
  padding: 1rem;
  border-radius: 12px;
  background: var(--bg);
  color: var(--text);
}

.kanban.dark {
  --bg: #1a1a1a;
  --card-bg: #262626;
  --text: #fafafa;
  --border: #404040;
}

.columns {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  padding: 1rem 0;
}

.column {
  min-width: 250px;
  background: var(--card-bg);
  border-radius: 8px;
  padding: 0.75rem;
}

.column h3 {
  font-size: 0.875rem;
  font-weight: 600;
  margin: 0 0 0.75rem 0;
  padding-bottom: 0.5rem;
  border-bottom: 1px solid var(--border);
}

.tasks {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.task {
  background: var(--bg);
  padding: 0.75rem;
  border-radius: 6px;
  cursor: grab;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  transition: transform 0.15s, box-shadow 0.15s;
}

.task:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.task.dragging {
  opacity: 0.5;
}

.priority {
  width: 8px;
  height: 8px;
  border-radius: 50%;
}

.priority.high { background: #ef4444; }
.priority.medium { background: #f59e0b; }
.priority.low { background: #22c55e; }

Next Steps

On this page