🥞PancakeJS

Your First App

This guide walks you through creating a complete Pancake app with views, actions, and data endpoints. By the end, you'll have a working app that runs in Claude, ChatGPT, and any MCP-compatible client.

Prerequisites

  • Node.js 18+ installed
  • pnpm, npm, or yarn package manager
  • Basic familiarity with TypeScript

Create a New Project

The CLI is the fastest way to get started:

npx create-pancake my-app
cd my-app
pnpm install

Or set up manually by following the Installation guide.

Understanding the Project Structure

The CLI creates a project with this structure:

my-app/
├── src/
│   ├── index.ts              # Server entry point
│   └── views/
│       └── hello/
│           ├── index.html    # HTML entry for Vite
│           ├── index.tsx     # React component
│           └── metadata.json # View metadata
├── scripts/
│   └── dev.ts                # Development server script
├── package.json
├── tsconfig.json
└── vite.config.ts

Views: Interactive UIs

Views are the core of Pancake apps. They combine a server-side handler (for data fetching) with a client-side UI (HTML or React).

Define a View

In src/index.ts, views are defined in the views object:

import { createApp, defineView } from '@pancake-apps/server';
import { z } from 'zod';

const app = createApp({
  name: 'my-app',
  version: '0.1.0',

  views: {
    weather: defineView({
      description: 'Show weather for a city',
      input: z.object({
        city: z.string().describe('City name'),
        units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
      }),
      data: z.object({
        city: z.string(),
        temp: z.number(),
        conditions: z.string(),
      }),
      handler: async ({ city, units }) => {
        const weather = await fetchWeatherAPI(city);
        const temp = units === 'fahrenheit' 
          ? weather.temp * 9/5 + 32 
          : weather.temp;
        return { city, temp, conditions: weather.conditions };
      },
      ui: { html: './src/views/weather/index.html' },
    }),
  },
});

app.start({ port: 3000 });

The view has:

  • description: What the AI sees when listing available tools
  • input: Zod schema for parameters (from AI or URL)
  • data: Zod schema for what your handler returns
  • handler: Server-side function that fetches data
  • ui: Path to the HTML or React component

Create the UI

Create src/views/weather/index.tsx:

import React from 'react';
import { createRoot } from 'react-dom/client';
    import {
      PancakeProvider,
      useViewParams,
      useTheme,
    } from '@pancake-apps/web';

    function WeatherView() {
      const { data } = useViewParams<
        { city: string; units?: string },
        { city: string; temp: number; conditions: string }
      >();
      const theme = useTheme();

  return (
    <div style={{ 
      padding: '2rem',
      fontFamily: 'system-ui',
          background: theme === 'dark' ? '#1a1a1a' : '#fff',
          color: theme === 'dark' ? '#fff' : '#000',
          minHeight: '100vh',
        }}>
          <h1>{data?.city}</h1>
          <p style={{ fontSize: '3rem' }}>{data?.temp}°</p>
          <p>{data?.conditions}</p>
    </div>
  );
}

function App() {
  return (
        <PancakeProvider>
          <WeatherView />
        </PancakeProvider>
  );
}

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

Create src/views/weather/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Weather</title>
</head>
<body>
  <div id="root"></div>
  <script type="module" src="./index.tsx"></script>
</body>
</html>

Create src/views/weather.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Weather</title>
  <style>
    body { font-family: system-ui; padding: 2rem; }
    .temp { font-size: 3rem; margin: 1rem 0; }
  </style>
</head>
<body>
  <h1 id="city">Loading...</h1>
  <p class="temp" id="temp"></p>
  <p id="conditions"></p>

  <script type="module">
    const waitForPancake = () => new Promise((resolve) => {
      if (window.pancake) return resolve(window.pancake);
      const check = setInterval(() => {
        if (window.pancake) {
          clearInterval(check);
          resolve(window.pancake);
        }
      }, 50);
    });

    const pancake = await waitForPancake();
    const data = pancake.adaptor.getToolOutput();

    document.getElementById('city').textContent = data?.city || 'Unknown';
    document.getElementById('temp').textContent = `${data?.temp || '--'}°`;
    document.getElementById('conditions').textContent = data?.conditions || '';
  </script>
</body>
</html>

Update your view definition:

ui: { html: './src/views/weather.html' }

Run and Test

pnpm dev

Open http://localhost:3000 to see your view.

Actions: Write Operations

Actions handle mutations and side effects. Unlike views, they don't render UI.

import { defineAction } from '@pancake-apps/server';

const app = createApp({
  name: 'my-app',
  version: '0.1.0',

  actions: {
    submitFeedback: defineAction({
      description: 'Submit user feedback',
      input: z.object({
        rating: z.number().min(1).max(5),
        comment: z.string().optional(),
      }),
      output: z.object({
        success: z.boolean(),
        id: z.string(),
      }),
      handler: async ({ rating, comment }) => {
        const id = await saveFeedback({ rating, comment });
        return { success: true, id };
      },
    }),
  },

  views: { /* ... */ },
});

Calling Actions from Views

In React views, use the useAction hook:

import { useAction } from '@pancake-apps/web';

function FeedbackForm() {
  const { dispatch } = useAction();
  const [rating, setRating] = useState(5);

  const handleSubmit = async () => {
    const result = await dispatch<{ success: boolean; id: string }>(
      'submitFeedback',
      { rating, comment: 'Great app!' }
    );
    console.log('Submitted:', result.id);
  };

  return (
    <div>
      <input 
        type="range" 
        min="1" 
        max="5" 
        value={rating}
        onChange={(e) => setRating(Number(e.target.value))}
      />
      <button onClick={handleSubmit}>Submit</button>
    </div>
  );
}

In vanilla HTML, use pancake.unified.action():

const result = await pancake.unified.action('submitFeedback', {
  rating: 5,
  comment: 'Great app!',
});

Data: Read Operations

Data endpoints are for queries and lookups. They return structured data without UI.

import { defineData } from '@pancake-apps/server';

const app = createApp({
  name: 'my-app',
  version: '0.1.0',

  data: {
    searchProducts: defineData({
      description: 'Search the product catalog',
      input: z.object({
        query: z.string(),
        category: z.string().optional(),
        limit: z.number().default(10),
      }),
      output: z.array(z.object({
        id: z.string(),
        name: z.string(),
        price: z.number(),
      })),
      handler: async ({ query, category, limit }) => {
        return searchProducts({ query, category, limit });
      },
    }),
  },

  views: { /* ... */ },
  actions: { /* ... */ },
});

Fetching Data from Views

In React:

import { useData } from '@pancake-apps/web';

function ProductSearch() {
  const { getData } = useData();
  const [products, setProducts] = useState([]);

  const handleSearch = async (query: string) => {
    const results = await getData<Product[]>('searchProducts', { query });
    setProducts(results);
  };

  return (
    <div>
      <input onChange={(e) => handleSearch(e.target.value)} />
      {products.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}

In vanilla HTML:

const products = await pancake.unified.data('searchProducts', {
  query: 'laptop',
  limit: 20,
});

View Discovery

Instead of defining views inline, you can auto-discover them from the filesystem:

import { createApp, discoverViews } from '@pancake-apps/server';

const app = createApp({
  name: 'my-app',
  version: '0.1.0',
  views: discoverViews('./src/views'),
});

Drop files in src/views/ and they become available:

src/views/
├── dashboard.html          # → view: "dashboard"
├── dashboard.json          # → metadata for dashboard
├── settings/
│   ├── index.html          # → view: "settings"
│   └── metadata.json       # → metadata for settings
└── weather.tsx             # → view: "weather"

Each view can have a metadata file (metadata.json or viewname.json):

{
  "description": "User dashboard with analytics",
  "visibility": "both"
}

Configuration Options

const app = createApp({
  name: 'my-app',
  version: '0.1.0',

  views: { /* ... */ },
  actions: { /* ... */ },
  data: { /* ... */ },

  config: {
    // Enable debug logging
    debug: true,

    // Target protocol (mcp, openai, or auto-detect)
    protocol: 'both',

    // Vite dev server port for hot reload
    devServer: {
      vitePort: 5173,
    },

    // Automatic tunneling
    tunnel: {
      provider: 'cloudflare', // or 'ngrok'
    },

    // CORS settings
    cors: {
      origin: ['https://example.com'],
      credentials: true,
    },
  },
});

React Hooks Reference

When using React views, these hooks are available:

HookReturnsDescription
useViewParams<I, D>(){ inputs, data }View inputs and server data
useViewState(initial)[state, setState]Persistent state across re-renders
useNavigation(){ navigate, say }Navigate views and message the AI
useAction(){ dispatch }Call actions (write operations)
useData(){ getData }Fetch data (read operations)
useTheme()'light' | 'dark'Current theme from host
useDisplayMode()'embedded' | 'standalone'How the view is displayed

Next Steps

You now understand the core concepts of Pancake apps. Explore further:

On this page