shadimage
utilcn
blockchatgpt-appsource: ok · hit

ChatGPT Widget Frontend

Frontend widget implementation, build script, types, and hooks for embedding a ChatGPT MCP widget.

npx shadcn add "https://utilcn.dev/r/chatgpt-app.json"
free

Where it goes

chat

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@tailwindcss/vite@vitejs/plugin-reactfast-globreactreact-domvite

Registry dependencies

cardskeleton

Files9 · source cached 10/8/2026, 10:26:50 PM

registry/default/chatgpt-app/index.jsx
import { createRoot } from 'react-dom/client';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useWidgetProps } from '@/registry/default/chatgpt-app/use-widget-props';

function App() {
  const { a, b, sum } = useWidgetProps({
    a: null,
    b: null,
    sum: null,
  });

  const hasData = a !== null && b !== null && sum !== null;

  return (
    <div className="flex items-center justify-center w-full min-h-screen">
      <Card className="w-full max-w-md p-6">
        <div className="space-y-4">
          <h2 className="text-xl font-bold">Sum Calculator</h2>

          <div className="space-y-2">
            <div className="flex justify-between">
              <span>A:</span>
              {hasData ? <span>{a}</span> : <Skeleton className="h-5 w-20" />}
            </div>

            <div className="flex justify-between">
              <span>B:</span>
              {hasData ? <span>{b}</span> : <Skeleton className="h-5 w-20" />}
            </div>

            <div className="flex justify-between font-bold">
              <span>Sum:</span>
              {hasData ? <span>{sum}</span> : <Skeleton className="h-5 w-20" />}
            </div>
          </div>
        </div>
      </Card>
    </div>
  );
}

createRoot(document.getElementById('add-root')).render(<App />);
registry/default/chatgpt-app/build-chatgpt-widgets.ts
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import fg from 'fast-glob';
import { build, type InlineConfig, type Plugin } from 'vite';
import pkg from '../../../package.json' with { type: 'json' }; // <-- TODO: Get to the package.json of your project

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..', '..'); // <-- TODO: Get to where you want your built `assets` directory to appear

const entries = fg.sync('src/**/index.{tsx,jsx}', {
  cwd: projectRoot,
  absolute: true,
});
const outDir = path.join(projectRoot, 'assets');

const PER_ENTRY_CSS_GLOB = '**/*.{css,pcss,scss,sass}';
const PER_ENTRY_CSS_IGNORE = '**/*.module.*'.split(',').map((s) => s.trim());
const GLOBAL_CSS_LIST = [path.join(projectRoot, 'src/index.css')];

const targets: string[] = ['add'];
const builtNames: string[] = [];

export function encryptPackageVersion(version: string): string {
  return crypto
    .createHash('sha256')
    .update(version, 'utf8')
    .digest('hex')
    .slice(0, 4);
}

function wrapEntryPlugin(
  virtualId: string,
  entryFile: string,
  cssPaths: string[],
): Plugin {
  return {
    name: `virtual-entry-wrapper:${entryFile}`,
    resolveId(id) {
      if (id === virtualId) return id;
    },
    load(id) {
      if (id !== virtualId) {
        return null;
      }

      const cssImports = cssPaths
        .map((css) => `import ${JSON.stringify(css)};`)
        .join('\n');

      return `
    ${cssImports}
    export * from ${JSON.stringify(entryFile)};

    import * as __entry from ${JSON.stringify(entryFile)};
    export default (__entry.default ?? __entry.App);

    import ${JSON.stringify(entryFile)};
  `;
    },
  };
}

fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });

for (const file of entries) {
  const name = path.basename(path.dirname(file));
  if (targets.length && !targets.includes(name)) {
    continue;
  }

  const entryAbs = path.resolve(file);
  const entryDir = path.dirname(entryAbs);

  // Collect CSS for this entry using the glob(s) rooted at its directory
  const perEntryCss = fg.sync(PER_ENTRY_CSS_GLOB, {
    cwd: entryDir,
    absolute: true,
    dot: false,
    ignore: PER_ENTRY_CSS_IGNORE,
  });

  // Global CSS (Tailwind, etc.), only include those that exist
  const globalCss = GLOBAL_CSS_LIST.filter((p) => fs.existsSync(p));

  // Final CSS list (global first for predictable cascade)
  const cssToInclude = [...globalCss, ...perEntryCss].filter((p) =>
    fs.existsSync(p),
  );

  const virtualId = `\0virtual-entry:${entryAbs}`;

  const createConfig = (): InlineConfig => ({
    root: projectRoot,
    plugins: [
      wrapEntryPlugin(virtualId, entryAbs, cssToInclude),
      tailwindcss(),
      react(),
      {
        name: 'remove-manual-chunks',
        outputOptions(options) {
          if ('manualChunks' in options) {
            delete (options as any).manualChunks;
          }
          return options;
        },
      },
    ],
    esbuild: {
      jsx: 'automatic',
      jsxImportSource: 'react',
      target: 'es2022',
    },
    build: {
      target: 'es2022',
      outDir,
      emptyOutDir: false,
      chunkSizeWarningLimit: 2000,
      minify: 'esbuild',
      cssCodeSplit: false,
      rollupOptions: {
        input: virtualId,
        output: {
          format: 'es',
          entryFileNames: `${name}.js`,
          inlineDynamicImports: true,
          assetFileNames: (info) =>
            (info.name || '').endsWith('.css')
              ? `${name}.css`
              : '[name]-[hash][extname]',
        },
        preserveEntrySignatures: 'allow-extension',
        treeshake: true,
      },
    },
  });

  console.group(`Building ${name} (react)`);
  await build(createConfig());
  console.groupEnd();
  builtNames.push(name);
  console.log(`Built ${name}`);
}

const outputs = fs.existsSync(outDir)
  ? fs
      .readdirSync(outDir)
      .filter((f) => f.endsWith('.js') || f.endsWith('.css'))
      .map((f) => path.join(outDir, f))
      .filter((p) => fs.existsSync(p))
  : [];

const renamed = [];

const h = encryptPackageVersion(pkg.version);

console.group('Hashing outputs');
for (const out of outputs) {
  const dir = path.dirname(out);
  const ext = path.extname(out);
  const base = path.basename(out, ext);
  const newName = path.join(dir, `${base}-${h}${ext}`);

  fs.renameSync(out, newName);
  renamed.push({ old: out, neu: newName });
  console.log(`${out} -> ${newName}`);
}
console.groupEnd();

console.log('new hash: ', h);

for (const name of builtNames) {
  const dir = outDir;
  const htmlPath = path.join(dir, `${name}-${h}.html`);
  const cssPath = path.join(dir, `${name}-${h}.css`);
  const jsPath = path.join(dir, `${name}-${h}.js`);

  const css = fs.existsSync(cssPath)
    ? fs.readFileSync(cssPath, { encoding: 'utf8' })
    : '';
  const js = fs.existsSync(jsPath)
    ? fs.readFileSync(jsPath, { encoding: 'utf8' })
    : '';

  const cssBlock = css ? `\n  <style>\n${css}\n  </style>\n` : '';
  const jsBlock = js ? `\n  <script type="module">\n${js}\n  </script>` : '';

  const html = [
    '<!doctype html>',
    '<html>',
    `<head>${cssBlock}</head>`,
    '<body>',
    `  <div id="${name}-root"></div>${jsBlock}`,
    '</body>',
    '</html>',
  ].join('\n');
  fs.writeFileSync(htmlPath, html, { encoding: 'utf8' });
  console.log(`${htmlPath} (generated)`);
}
registry/default/chatgpt-app/chatgpt-types.ts
export type OpenAiGlobals<
  ToolInput = UnknownObject,
  ToolOutput = UnknownObject,
  ToolResponseMetadata = UnknownObject,
  WidgetState = UnknownObject,
> = {
  // visuals
  theme: Theme;

  userAgent: UserAgent;
  locale: string;

  // layout
  maxHeight: number;
  displayMode: DisplayMode;
  safeArea: SafeArea;

  // state
  toolInput: ToolInput;
  toolOutput: ToolOutput | null;
  toolResponseMetadata: ToolResponseMetadata | null;
  widgetState: WidgetState | null;
  setWidgetState: (state: WidgetState) => Promise<void>;
};

// currently copied from types.ts in chatgpt/web-sandbox.
// Will eventually use a public package.
type API = {
  callTool: CallTool;
  sendFollowUpMessage: (args: { prompt: string }) => Promise<void>;
  openExternal(payload: { href: string }): void;

  // Layout controls
  requestDisplayMode: RequestDisplayMode;
};

export type UnknownObject = Record<string, unknown>;

export type Theme = 'light' | 'dark';

export type SafeAreaInsets = {
  top: number;
  bottom: number;
  left: number;
  right: number;
};

export type SafeArea = {
  insets: SafeAreaInsets;
};

export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';

export type UserAgent = {
  device: { type: DeviceType };
  capabilities: {
    hover: boolean;
    touch: boolean;
  };
};

/** Display mode */
export type DisplayMode = 'pip' | 'inline' | 'fullscreen';
export type RequestDisplayMode = (args: { mode: DisplayMode }) => Promise<{
  /**
   * The granted display mode. The host may reject the request.
   * For mobile, PiP is always coerced to fullscreen.
   */
  mode: DisplayMode;
}>;

export type CallToolResponse = {
  result: string;
};

/** Calling APIs */
export type CallTool = (
  name: string,
  args: Record<string, unknown>,
) => Promise<CallToolResponse>;

/** Extra events */
export const SET_GLOBALS_EVENT_TYPE = 'openai:set_globals';
export class SetGlobalsEvent extends CustomEvent<{
  globals: Partial<OpenAiGlobals>;
}> {
  readonly type = SET_GLOBALS_EVENT_TYPE;
}

/**
 * Global oai object injected by the web sandbox for communicating with chatgpt host page.
 */
declare global {
  interface Window {
    openai: API & OpenAiGlobals;
  }

  interface WindowEventMap {
    [SET_GLOBALS_EVENT_TYPE]: SetGlobalsEvent;
  }
}
registry/default/chatgpt-app/media-queries.ts
function matchMediaQuery(query: string) {
  return window.matchMedia(query).matches;
}

function createMediaQueryFn(query: string) {
  return () => matchMediaQuery(query);
}

export const prefersReducedMotion = createMediaQueryFn(
  '(prefers-reduced-motion: reduce)',
);

export const isPrimarilyTouchDevice = createMediaQueryFn('(pointer: coarse)');

export const isHoverAvailable = createMediaQueryFn('(hover: hover)');
registry/default/chatgpt-app/use-openai-global.ts
import { useSyncExternalStore } from 'react';
import {
  type OpenAiGlobals,
  SET_GLOBALS_EVENT_TYPE,
  type SetGlobalsEvent,
} from '@/registry/default/chatgpt-app/chatgpt-types';

export function useOpenAiGlobal<K extends keyof OpenAiGlobals>(
  key: K,
): OpenAiGlobals[K] | null {
  return useSyncExternalStore(
    (onChange) => {
      if (typeof window === 'undefined') {
        return () => {};
      }

      const handleSetGlobal = (event: SetGlobalsEvent) => {
        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] ?? null,
    () => window.openai?.[key] ?? null,
  );
}
registry/default/chatgpt-app/use-display-mode.ts
import type { DisplayMode } from '@/registry/default/chatgpt-app/chatgpt-types';
import { useOpenAiGlobal } from '@/registry/default/chatgpt-app/use-openai-global';

export const useDisplayMode = (): DisplayMode | null => {
  return useOpenAiGlobal('displayMode');
};
registry/default/chatgpt-app/use-max-height.ts
import { useOpenAiGlobal } from '@/registry/default/chatgpt-app/use-openai-global';

export const useMaxHeight = (): number | null => {
  return useOpenAiGlobal('maxHeight');
};
registry/default/chatgpt-app/use-widget-props.ts
import { useOpenAiGlobal } from '@/registry/default/chatgpt-app/use-openai-global';

export function useWidgetProps<T extends Record<string, unknown>>(
  defaultState?: T | (() => T),
): T {
  const props = useOpenAiGlobal('toolOutput') as T;

  const fallback =
    typeof defaultState === 'function'
      ? (defaultState as () => T | null)()
      : (defaultState ?? null);

  return props ?? fallback;
}
registry/default/chatgpt-app/use-widget-state.ts
import { type SetStateAction, useCallback, useEffect, useState } from 'react';
import type { UnknownObject } from '@/registry/default/chatgpt-app/chatgpt-types';
import { useOpenAiGlobal } from '@/registry/default/chatgpt-app/use-openai-global';

export function useWidgetState<T extends UnknownObject>(
  defaultState: T | (() => T),
): readonly [T, (state: SetStateAction<T>) => void];
export function useWidgetState<T extends UnknownObject>(
  defaultState?: T | (() => T | null) | null,
): readonly [T | null, (state: SetStateAction<T | null>) => void];
export function useWidgetState<T extends UnknownObject>(
  defaultState?: T | (() => T | null) | null,
): readonly [T | null, (state: SetStateAction<T | null>) => void] {
  const widgetStateFromWindow = useOpenAiGlobal('widgetState') as T;

  const [widgetState, _setWidgetState] = useState<T | null>(() => {
    if (widgetStateFromWindow != null) {
      return widgetStateFromWindow;
    }

    return typeof defaultState === 'function'
      ? defaultState()
      : (defaultState ?? null);
  });

  useEffect(() => {
    _setWidgetState(widgetStateFromWindow);
  }, [widgetStateFromWindow]);

  const setWidgetState = useCallback(
    (state: SetStateAction<T | null>) => {
      _setWidgetState((prevState) => {
        const newState = typeof state === 'function' ? state(prevState) : state;

        if (newState != null) {
          window.openai.setWidgetState(newState);
        }

        return newState;
      });
    },
    [window.openai.setWidgetState],
  );

  return [widgetState, setWidgetState] as const;
}

More from utilcn

Browse all components in this library →