The Frontend: The OpenAI Bridge

The frontend code lives in 05-building-a-chatgpt-app/note-taking-app/frontend in the materials repo. It’s a standard React app bundled with Vite, designed to run in ChatGPT’s iframe. Direct database access is impossible due to sandboxing, so all communication flows through the host via window.openai—the bridge injected by ChatGPT.

Prerequisites: Ensure the backend is configured (from the previous section). This project ships with a prebuilt dist/index.html, so you can preview via the backend’s /dashboard route right away. Only run npm run build if you change the React code and need to regenerate the bundle.

Think of window.openai as a secure portal: It provides data injection, tool calling, and state persistence, but with restrictions for security (e.g., no arbitrary network access without CSP).

The Bridge Object

When rendering the widget, ChatGPT injects window.openai into the iframe. You abstract it with React hooks in hooks/useOpenAi.js for easy subscription and re-renders.

For example:

export const useToolOutput = () => {
  return useOpenAiGlobal('toolOutput');
};

The core helper, useOpenAiGlobal, uses React’s useSyncExternalStore to subscribe to host updates:

export const useOpenAiGlobal = (key) => {
  return useSyncExternalStore(
    (onChange) => {
      const handleSetGlobal = (event) => {
        const value = event.detail?.globals?.[key];
        if (value === undefined) return;
        onChange();
      };

      window.addEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal, {
        passive: true,
      });

      return () => {
        window.removeEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal);
      };
    },
    () => window.openai?.[key]
  );
};
  • SET_GLOBALS_EVENT_TYPE: This is ‘openai:set_globals’—an event emitted by the host when globals like toolOutput or widgetState update.
  • Why useSyncExternalStore? Recommended by React for external data sources. It ensures consistent reads and prevents stale state during async updates from the host.
  • Passive Listener: Improves performance by not blocking the event loop.
  • Fallback: If not in ChatGPT (e.g., local testing), window.openai is undefined—handle with checks like isInChatGPT.

Per SDK reference, window.openai includes context like theme, locale, and userAgent—subscribe to these for adaptive UIs (e.g., dark mode).

Receiving Data with toolOutput

The backend’s structuredContent is injected as window.openai.toolOutput. In App.jsx, hydrate once on mount to avoid overwriting user changes:

const toolOutput = useToolOutput();
const hasHydratedFromToolOutput = useRef(false);

useEffect(() => {
  if (toolOutput?.notes && !hasHydratedFromToolOutput.current) {
    setNotes(toolOutput.notes);
    setError(null);
    hasHydratedFromToolOutput.current = true;
    return;
  }
  if (isInChatGPT && !hasHydratedFromToolOutput.current) {
    refreshNotes();
  }
}, [toolOutput, isInChatGPT]);
  • toolOutput: Read-only; contains structured data like {notes: […]}. The model sees it too, so keep it JSON-serializable.
  • refreshNotes(): A fallback call to list_notes via the bridge if no initial data.
  • Best Practice: Handle errors (e.g., setError) if toolOutput is malformed—log to console for debugging.

Additional SDK props: window.openai.toolInput (invocation args) and toolResponseMetadata (_meta, widget-only).

Calling Tools from the UI

UI events invoke MCP tools via the host. Abstract in lib/sdk.js:

export const AppSDK = {
  callTool: async (toolName, args = {}) => {
    if (window.openai?.callTool) {
      try {
        const result = await window.openai.callTool(toolName, args);
        return result?.structuredContent ?? result ?? null;
      } catch (e) {
        throw new Error(`Tool call failed: ${e.message}`);
      }
    }

    throw new Error('ChatGPT host is not available.');
  },
};

In App.jsx, use it for actions like saving:

const handleSave = async (note) => {
  const result = await AppSDK.callTool('create_note', {
    title: note.title,
    content: note.content
  });

  if (result?.notes && Array.isArray(result.notes)) {
    setNotes(result.notes);
  } else {
    await refreshNotes();
  }
  setWidgetState(prev => ({ ...prev, view: 'list' }));
};
  • callTool: Mirrors model calls; returns a promise with the tool’s structuredContent. Requires openai/widgetAccessible: True in backend metadata.
  • Error Handling: Throw if not in host; in production, show UI alerts (e.g., “Connection lost”).

Managing UI State with widgetState

Persist ephemeral UI state (e.g., current view) via window.openai.widgetState and setWidgetState:

const [widgetState, setWidgetState] = useWidgetState(() => ({
  view: 'list'
}));

Update on interactions:

const handleCreate = () => {
  setActiveNote({ title: '', content: '' });
  setWidgetState(prev => ({ ...prev, view: 'create' }));
};

const handleSelect = (note) => {
  setActiveNote(note);
  setWidgetState(prev => ({ ...prev, view: 'detail' }));
};
  • widgetState: A snapshot persisted by the host between renders. Call setWidgetState synchronously after changes.
  • Separation: UI state (views) is host-managed and ephemeral; app data (notes) is backend-persisted in SQLite.
  • Subscription: Use useOpenAiGlobal('widgetState') for reactivity. Per SDK, it’s great for modals or tabs.

Other methods: sendFollowUpMessage to post AI messages, requestDisplayMode for fullscreen, notifyIntrinsicHeight for dynamic sizing.

Summary

  1. Backend sends structuredContent.
  2. Host injects as toolOutput; subscribe via hooks.
  3. Render from data; call tools on events.
  4. Persist UI with widgetState.

This bridge turns your React app into a seamless ChatGPT extension. See it in the demos!

See forum comments
Download course materials from Github
Previous: The Backend: Serving Resources Next: Server & Tunnel Setup