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 devThen 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
-
Expose your local server with a tool like ngrok:
ngrok http 3000 -
Create a Custom GPT in ChatGPT with your ngrok URL
-
Test your widget in actual ChatGPT conversations
-
Add your MCP server to Claude Desktop config:
{ "mcpServers": { "my-app": { "command": "node", "args": ["path/to/your/server.js"] } } } -
Restart Claude Desktop
-
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
- Publishing Apps: Submit your widget to app directories
- Capabilities: Handle platform differences
- Examples: More widget patterns and examples