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;
}