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.
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.
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);
}
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.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: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: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:This produces product-catalog.html, shopping-cart.html, and checkout.html in dist/widgets/, each fully self-contained.Continue to Integrate Gr4vy Embed.