Skip to main content
Widgets are the user-facing layer of your ChatGPT app—self-contained HTML files served as MCP resources and embedded by ChatGPT as iframes. Any approach that produces a single self-contained HTML file works:
  • React + Vite + vite-plugin-singlefile — Build widgets as React SPAs bundled into one HTML file (TypeScript reference).
  • Self-contained HTML — Write standalone HTML files with inline CSS and JS. No build step needed (Python reference).
The bridge API and lifecycle pattern below apply to both approaches.
1

Build the ChatGPT widget bridge

The widget bridge wraps the window.openai API that ChatGPT provides inside widget iframes. The TypeScript example creates a shared module; the Python example calls window.openai directly inline.
// src/widgets/shared/openai-bridge.ts
declare global {
  interface Window {
    openai?: {
      toolOutput?: unknown;
      widgetState?: unknown;
      setWidgetState?: (state: unknown) => void;
      sendFollowUpMessage?: (msg: { prompt: string }) => void;
    };
  }
}

/**
 * Poll for window.openai.toolOutput to become available.
 * This is how the widget receives data from a tool call.
 * Times out after 30 seconds.
 */
export function waitForToolOutput<T>(timeoutMs = 30000): Promise<T> {
  return new Promise((resolve, reject) => {
    const deadline = Date.now() + timeoutMs;
    function check() {
      if (window.openai?.toolOutput) {
        resolve(window.openai.toolOutput as T);
      } else if (Date.now() > deadline) {
        reject(new Error("Timed out waiting for tool output"));
      } else {
        setTimeout(check, 100);
      }
    }
    check();
  });
}

/**
 * Get persisted widget state (survives re-renders within a session).
 * Use this to restore cart contents, filter selections, etc.
 */
export function getWidgetState<T>(): T | null {
  return (window.openai?.widgetState as T) ?? null;
}

/**
 * Persist widget state to ChatGPT's context.
 * Call this whenever state changes that should survive widget re-renders.
 */
export function setWidgetState(state: unknown): void {
  window.openai?.setWidgetState?.(state);
}

/**
 * Send a follow-up message to ChatGPT, triggering a new model turn.
 * Use this to instruct ChatGPT to call the next tool — for example,
 * when the user clicks "Proceed to Checkout" in the catalog widget.
 */
export function sendFollowUp(prompt: string): void {
  window.openai?.sendFollowUpMessage?.({ prompt });
}

/**
 * Listen for subsequent tool result updates via postMessage.
 * When ChatGPT re-invokes a tool (e.g., start_checkout after the user
 * was in the catalog), this callback fires with the new structured content.
 * Returns an unsubscribe function.
 */
export function onToolResult<T>(callback: (data: T) => void): () => void {
  function handler(ev: MessageEvent) {
    if (ev.source !== window.parent) return;
    const msg = ev.data;
    if (!msg || msg.jsonrpc !== "2.0") return;
    if (msg.method === "ui/notifications/tool-result") {
      callback(msg.params?.structuredContent as T);
    }
  }
  window.addEventListener("message", handler);
  return () => window.removeEventListener("message", handler);
}
2

Build the widgets

This section applies to the TypeScript/React implementation only. If you are writing self-contained HTML files directly, as the Python reference implementation does, skip to Integrate Gr4vy Embed.
Each widget is a React SPA bundled into a single HTML file. The UI is app-specific—see the reference implementation for a complete example. This guide covers the communication pattern every widget shares.Every widget follows the same file layout:
src/widgets/<widget-name>/
├── index.html    # HTML template with <div id="root"></div>
├── main.tsx      # createRoot(document.getElementById("root")!).render(<App />)
├── App.tsx       # Your widget component
└── styles.css    # @import "tailwindcss" + base styles
Every widget follows the same communication lifecycle:
src/widgets/<widget-name>/App.tsx
import { useState, useEffect } from "react";
import {
  waitForToolOutput,
  getWidgetState,
  setWidgetState,
  sendFollowUp,
  onToolResult,
} from "../shared/openai-bridge";

export function App() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // 1. Restore any persisted state (e.g., cart contents) from a previous render
    const saved = getWidgetState();
    if (saved) { /* restore state from saved */ }

    // 2. Wait for the tool's structuredContent to arrive
    waitForToolOutput().then((toolData) => {
      setData(toolData);
      setLoading(false);
    });

    // 3. Listen for updates if ChatGPT calls another tool while this widget is open
    const unsub = onToolResult((newData) => {
      setData(newData);
    });
    return unsub;
  }, []);

  // 4. Persist widget state whenever it changes
  useEffect(() => {
    setWidgetState({ /* your state to persist */ });
  }, [data]);

  // 5. Trigger the next step in the flow (e.g., user clicks "Checkout")
  function handleNextAction() {
    sendFollowUp("Call the start_checkout tool with these items: [...]");
  }

  // 6. Render your application-specific UI
  return <div>{/* Your UI here */}</div>;
}
The checkout widget follows this same pattern with the addition of Gr4vy Embed. See Integrate Gr4vy Embed.
3

Configure the widget build process

Compile widgets into self-contained HTML files using Vite and vite-plugin-singlefile. The config accepts a WIDGET environment variable so the same file builds every widget:
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
import tailwindcss from "@tailwindcss/vite";
import { resolve } from "path";

const widget = process.env.WIDGET;

export default defineConfig({
  plugins: [react(), tailwindcss(), viteSingleFile()],
  root: `src/widgets/${widget}`,
  build: {
    outDir: resolve(__dirname, "dist/widgets"),
    emptyOutDir: false,
    rollupOptions: {
      input: resolve(__dirname, `src/widgets/${widget}/index.html`),
    },
  },
});
Create scripts/build-widgets.ts to build each widget and rename the output:
scripts/build-widgets.ts
import { execSync } from "child_process";
import { rmSync, mkdirSync, renameSync, existsSync } from "fs";
import { resolve } from "path";

const widgets = ["product-catalog", "shopping-cart", "checkout"];
const outDir = resolve(import.meta.dirname!, "..", "dist", "widgets");

// Clean output directory
rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });

for (const widget of widgets) {
  console.log(`\nBuilding widget: ${widget}...`);
  execSync(`npx vite build`, {
    cwd: resolve(import.meta.dirname!, ".."),
    env: { ...process.env, WIDGET: widget },
    stdio: "inherit",
  });

  // Vite outputs to dist/widgets/index.html — rename to the widget name
  const srcFile = resolve(outDir, "index.html");
  const destFile = resolve(outDir, `${widget}.html`);
  if (existsSync(srcFile)) {
    renameSync(srcFile, destFile);
  }
}

console.log("\nAll widgets built successfully!");
Run the build:
npm run build:widgets
This produces product-catalog.html, shopping-cart.html, and checkout.html in dist/widgets/, each fully self-contained.Continue to Integrate Gr4vy Embed.