> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gr4vy.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build the widgets

> Build the widget bridge, self-contained HTML widgets, and the build pipeline.

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](https://github.com/gr4vy/gr4vy-typescript-adk)).
* **Self-contained HTML** — Write standalone HTML files with inline CSS and JS. No build step needed ([Python reference](https://github.com/gr4vy/gr4vy-python-adk)).

The bridge API and lifecycle pattern below apply to both approaches.

<Steps>
  <Step title="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.

    <CodeGroup>
      ```ts TypeScript theme={"system"}
      // 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);
      }
      ```

      ```js Python theme={"system"}
      // assets/product-catalog.html (inline <script>)

      // Poll for window.openai.toolOutput to become available.
      // This is how the widget receives data from a tool call.
      function checkOpenAI() {
        if (window.openai && window.openai.toolOutput) {
          onToolResult(window.openai.toolOutput);
        } else {
          setTimeout(checkOpenAI, 100);
        }
      }
      checkOpenAI();

      // Handle incoming data from the tool call.
      // Restore persisted widget state if available.
      function onToolResult(data) {
        if (!data) return;
        if (data.products) {
          products = data.products;
        }
        if (window.openai && window.openai.widgetState && window.openai.widgetState.cart) {
          cart = window.openai.widgetState.cart;
        }
        renderFilters();
        renderProductGrid();
        renderCart();
      }

      // Persist widget state so it survives re-renders within the same session.
      function saveState() {
        try {
          if (window.openai && window.openai.setWidgetState) {
            window.openai.setWidgetState({ cart: cart });
          }
        } catch (e) {}
      }

      // Send a follow-up message to ChatGPT to trigger the next tool.
      function doCheckout() {
        var checkoutItems = getCartItems().map(function(i) {
          return { product_id: i.id, name: i.name, price: i.price, quantity: i.quantity };
        });
        window.openai?.sendFollowUpMessage?.({
          prompt: "Call the start_checkout tool with these items: " + JSON.stringify(checkoutItems)
        });
      }

      // Listen for subsequent tool result updates via postMessage.
      window.addEventListener("message", function(ev) {
        if (ev.source !== window.parent) return;
        var msg = ev.data;
        if (!msg || msg.jsonrpc !== "2.0") return;
        if (msg.method === "ui/notifications/tool-result") {
          onToolResult(msg.params && msg.params.structuredContent);
        }
      });
      ```
    </CodeGroup>
  </Step>

  <Step title="Build the widgets">
    <Note>
      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](/guides/payments/chatgpt-app/gr4vy-embed).
    </Note>

    Each widget is a React SPA bundled into a single HTML file. The UI is app-specific—see the [reference implementation](https://github.com/gr4vy/gr4vy-typescript-adk) for a complete example. This guide covers the **communication pattern** every widget shares.

    Every widget follows the same file layout:

    ```text theme={"system"}
    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:

    ```tsx src/widgets/<widget-name>/App.tsx theme={"system"}
    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](/guides/payments/chatgpt-app/gr4vy-embed).
  </Step>

  <Step title="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:

    ```ts vite.config.ts theme={"system"}
    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:

    ```ts scripts/build-widgets.ts theme={"system"}
    import { execSync } from "child_process";
    import { rmSync, mkdirSync, renameSync, existsSync } from "fs";
    import { dirname, resolve } from "path";
    import { fileURLToPath } from "url";

    const __dirname = dirname(fileURLToPath(import.meta.url));
    const widgets = ["product-catalog", "shopping-cart", "checkout"];
    const outDir = resolve(__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(__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:

    ```bash theme={"system"}
    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](/guides/payments/chatgpt-app/gr4vy-embed).
  </Step>
</Steps>
