Interactive widget UIs for ChatGPT and Claude. Skybridge rendering, structuredContent, human-in-the-loop patterns.
$ npx snappy-skills install mcp-ui-apps
$ npx snappy-skills install --all
$ npx snappy-skills update
Build MCP servers that render interactive widget UIs inline in ChatGPT and Claude. Both platforms support rich, interactive content through MCP — the patterns are nearly identical.
text/html+skybridge patternBoth ChatGPT and Claude support MCP servers that return rich UI through a three-layer pattern:
1. TOOL returns data
↓ structuredContent + _meta["openai/outputTemplate"]
2. RESOURCE serves HTML shell
↓ text/html+skybridge mimeType
3. WIDGET JS reads data, renders UI
↓ window.openai.toolOutput (ChatGPT) / equivalent API (Claude)
4. Client renders the widget inline
The key insight: widgets are not passive displays. They can call other tools, send messages on behalf of the user, and persist their own state.
typescriptexport function getWidgetMeta() {
return {
'openai/outputTemplate': 'ui://mymcp/list',
'openai/toolInvocation/invoking': 'Loading...',
'openai/toolInvocation/invoked': 'Ready',
'openai/widgetAccessible': true,
};
}
export async function handler(params) {
const data = await fetchData(params);
return {
content: [{ type: 'text', text: 'Summary for non-widget contexts' }],
structuredContent: { items: data, title: 'My Data' },
_meta: getWidgetMeta(),
};
}
typescriptserver.resource('ui-list', 'ui://mymcp/list', {
title: 'List Widget',
mimeType: 'text/html+skybridge',
}, async (uri) => ({
contents: [{
uri: uri.href,
mimeType: 'text/html+skybridge',
text: `<div id="root"></div>
<link rel="stylesheet" href="https://mymcp.workers.dev/widgets/list.css">
<script src="https://mymcp.workers.dev/widgets/list.js"></script>`,
}],
}));
javascript(function() {
var data = window.openai ? window.openai.toolOutput : null;
if (!data) return;
var items = data.structuredContent?.items || [];
document.getElementById('root').innerHTML = items.map(renderItem).join('');
})();
javascript// Invoke another MCP tool from the widget
button.addEventListener('click', function() {
window.openai.callTool('my_tool', { id: 123 });
});
// Send a message as if the user typed it
window.openai.sendFollowUpMessage({ prompt: 'Merge these entities?' });
// Persist UI state across re-renders
window.openai.setWidgetState({ selectedTab: 'entities' });
| API | Purpose |
|---|---|
toolOutput |
Read data from the MCP tool that spawned this widget |
callTool(name, args) |
Invoke another MCP tool |
sendFollowUpMessage({ prompt }) |
Post a message as the user |
setWidgetState(state) |
Persist UI state |
widgetState |
Read persisted state |
theme |
'light' or 'dark' — current client theme |
maxHeight |
Max widget height in pixels |
displayMode |
'inline' / 'pip' / 'fullscreen' |
requestDisplayMode({ mode }) |
Request fullscreen or picture-in-picture |
openExternal({ href }) |
Open a link in the browser |
src/
├── index.ts ← Worker entry (OAuth + McpAgent)
├── oauth-handler.ts ← OAuth approval UI
├── resources/
│ └── ui.ts ← Widget resources (text/html+skybridge)
├── widgets/
│ └── assets.ts ← Widget JS/CSS as string constants
├── tools/
│ └── meta/widgets/ ← Widget tools (structuredContent + _meta)
└── services/
└── */adapter.ts ← Backend service adapters
text/html+skybridge#The mimeType that tells the client to render HTML as an interactive widget in a sandboxed iframe.
structuredContent#JSON data returned by the tool that flows to the widget via window.openai.toolOutput. This is how you pass data from your backend to the UI.
_meta["openai/outputTemplate"]#Points the tool to the resource URI that serves the widget HTML. Both must use the same URI.
Widget JS/CSS must be served from a URL accessible to the client's sandbox — typically the same Cloudflare Worker via /widgets/* routes.
| Wrong | Right |
|---|---|
| Building a 1:1 clone of your platform | Conversation-first: glanceable summaries, action enablers |
| More than 2 CTAs per card | Max 2: one primary, one secondary |
| Deep navigation (tabs, sidebars) | Single-purpose. Need more? Spawn a new tool call |
| Passive display only | Interactive: callTool, sendFollowUpMessage |
| Wrong | Right |
|---|---|
| Widget shows "No data" then works on refresh | Listen for openai:set_globals — data arrives async |
Inline <script> tags |
External scripts: <script src="https://..."> |
| Returning HTML from tool | Return structuredContent, let widget JS render |
text/html mimeType |
Must use text/html+skybridge |
| Resource URI mismatch | outputTemplate URI must match registered resource |
_meta in registration overrides handler _meta |
Both must match. See widgets.md |
structuredContent + _metatext/html+skybridge/widgets/* with CORSnpm run build — no TypeScript errorsnpm run deploy — deployed to Workersopenai:set_globals listener — no empty widget flash| Topic | File |
|---|---|
| Conversation-first design philosophy | conversation-first-design.md |
| Reactive widgets + empty widget fix | reactive-widgets.md |
| Full architecture deep dive | architecture.md |
| Basic widget implementation | widgets.md |
| Human-in-the-loop patterns | interactive-widgets.md |
| One widget per domain pattern | domain-widgets.md |
| UI design guidelines | ui-guidelines.md |
| CDN asset hosting + cache busting | cdn-assets.md |
| Step-by-step tutorial | quickstart.md |
| Complete code examples | examples.md |
---
name: mcp-ui-apps
description: Build MCP servers with interactive widget UIs for ChatGPT and Claude. OAuth2 PKCE on Cloudflare Workers, McpAgent with Durable Objects, text/html+skybridge rendering, structuredContent data flow, human-in-the-loop patterns (callTool, sendFollowUpMessage, setWidgetState), conversation-first design, reactive data hydration, and CDN-hosted widget assets.
---
# MCP UI Apps — Interactive Widgets for ChatGPT and Claude
Build MCP servers that render interactive widget UIs inline in ChatGPT and Claude. Both platforms support rich, interactive content through MCP — the patterns are nearly identical.
## When to Use
- Building an MCP server with visual, interactive output
- Creating widgets that render inline in ChatGPT or Claude
- Working with the `text/html+skybridge` pattern
- Setting up OAuth2 on Cloudflare Workers
- Implementing human-in-the-loop: widgets that trigger actions
- Hosting widget JS/CSS assets on a CDN
## How It Works
Both ChatGPT and Claude support MCP servers that return rich UI through a three-layer pattern:
```
1. TOOL returns data
↓ structuredContent + _meta["openai/outputTemplate"]
2. RESOURCE serves HTML shell
↓ text/html+skybridge mimeType
3. WIDGET JS reads data, renders UI
↓ window.openai.toolOutput (ChatGPT) / equivalent API (Claude)
4. Client renders the widget inline
```
The key insight: **widgets are not passive displays**. They can call other tools, send messages on behalf of the user, and persist their own state.
---
## Quick Start
### 1. Tool returns data + points to widget resource
```typescript
export function getWidgetMeta() {
return {
'openai/outputTemplate': 'ui://mymcp/list',
'openai/toolInvocation/invoking': 'Loading...',
'openai/toolInvocation/invoked': 'Ready',
'openai/widgetAccessible': true,
};
}
export async function handler(params) {
const data = await fetchData(params);
return {
content: [{ type: 'text', text: 'Summary for non-widget contexts' }],
structuredContent: { items: data, title: 'My Data' },
_meta: getWidgetMeta(),
};
}
```
### 2. Resource serves minimal HTML template
```typescript
server.resource('ui-list', 'ui://mymcp/list', {
title: 'List Widget',
mimeType: 'text/html+skybridge',
}, async (uri) => ({
contents: [{
uri: uri.href,
mimeType: 'text/html+skybridge',
text: `<div id="root"></div>
<link rel="stylesheet" href="https://mymcp.workers.dev/widgets/list.css">
<script src="https://mymcp.workers.dev/widgets/list.js"></script>`,
}],
}));
```
### 3. Widget JS reads data and renders
```javascript
(function() {
var data = window.openai ? window.openai.toolOutput : null;
if (!data) return;
var items = data.structuredContent?.items || [];
document.getElementById('root').innerHTML = items.map(renderItem).join('');
})();
```
### 4. Widget calls tools (human-in-the-loop)
```javascript
// Invoke another MCP tool from the widget
button.addEventListener('click', function() {
window.openai.callTool('my_tool', { id: 123 });
});
// Send a message as if the user typed it
window.openai.sendFollowUpMessage({ prompt: 'Merge these entities?' });
// Persist UI state across re-renders
window.openai.setWidgetState({ selectedTab: 'entities' });
```
---
## Widget API Reference
| API | Purpose |
|-----|---------|
| `toolOutput` | Read data from the MCP tool that spawned this widget |
| `callTool(name, args)` | Invoke another MCP tool |
| `sendFollowUpMessage({ prompt })` | Post a message as the user |
| `setWidgetState(state)` | Persist UI state |
| `widgetState` | Read persisted state |
| `theme` | `'light'` or `'dark'` — current client theme |
| `maxHeight` | Max widget height in pixels |
| `displayMode` | `'inline'` / `'pip'` / `'fullscreen'` |
| `requestDisplayMode({ mode })` | Request fullscreen or picture-in-picture |
| `openExternal({ href })` | Open a link in the browser |
---
## Architecture
- **Runtime**: Cloudflare Workers (serverless, global edge)
- **Auth**: OAuth2 PKCE via @cloudflare/workers-oauth-provider
- **State**: Durable Objects + SQLite
- **Base**: McpAgent from agents/mcp
- **Validation**: Zod schemas
- **Protocol**: MCP over SSE
```
src/
├── index.ts ← Worker entry (OAuth + McpAgent)
├── oauth-handler.ts ← OAuth approval UI
├── resources/
│ └── ui.ts ← Widget resources (text/html+skybridge)
├── widgets/
│ └── assets.ts ← Widget JS/CSS as string constants
├── tools/
│ └── meta/widgets/ ← Widget tools (structuredContent + _meta)
└── services/
└── */adapter.ts ← Backend service adapters
```
---
## Critical Concepts
### `text/html+skybridge`
The mimeType that tells the client to render HTML as an interactive widget in a sandboxed iframe.
### `structuredContent`
JSON data returned by the tool that flows to the widget via `window.openai.toolOutput`. This is how you pass data from your backend to the UI.
### `_meta["openai/outputTemplate"]`
Points the tool to the resource URI that serves the widget HTML. Both must use the same URI.
### CDN-hosted assets
Widget JS/CSS must be served from a URL accessible to the client's sandbox — typically the same Cloudflare Worker via `/widgets/*` routes.
---
## Common Mistakes
### Design
| Wrong | Right |
|-------|-------|
| Building a 1:1 clone of your platform | Conversation-first: glanceable summaries, action enablers |
| More than 2 CTAs per card | Max 2: one primary, one secondary |
| Deep navigation (tabs, sidebars) | Single-purpose. Need more? Spawn a new tool call |
| Passive display only | Interactive: callTool, sendFollowUpMessage |
### Technical
| Wrong | Right |
|-------|-------|
| Widget shows "No data" then works on refresh | Listen for `openai:set_globals` — data arrives async |
| Inline `<script>` tags | External scripts: `<script src="https://...">` |
| Returning HTML from tool | Return `structuredContent`, let widget JS render |
| `text/html` mimeType | Must use `text/html+skybridge` |
| Resource URI mismatch | `outputTemplate` URI must match registered resource |
| `_meta` in registration overrides handler `_meta` | Both must match. See [widgets.md](widgets.md) |
---
## Deployment Checklist
- [ ] OAuth2 PKCE configured with KV namespace
- [ ] McpAgent class with Durable Object binding
- [ ] Widget tools return `structuredContent` + `_meta`
- [ ] Resources registered with `text/html+skybridge`
- [ ] Widget assets served from `/widgets/*` with CORS
- [ ] `npm run build` — no TypeScript errors
- [ ] `npm run deploy` — deployed to Workers
- [ ] Test in client — widgets render inline
- [ ] Verify `openai:set_globals` listener — no empty widget flash
---
## Deep Dives
| Topic | File |
|-------|------|
| Conversation-first design philosophy | [conversation-first-design.md](conversation-first-design.md) |
| Reactive widgets + empty widget fix | [reactive-widgets.md](reactive-widgets.md) |
| Full architecture deep dive | [architecture.md](architecture.md) |
| Basic widget implementation | [widgets.md](widgets.md) |
| Human-in-the-loop patterns | [interactive-widgets.md](interactive-widgets.md) |
| One widget per domain pattern | [domain-widgets.md](domain-widgets.md) |
| UI design guidelines | [ui-guidelines.md](ui-guidelines.md) |
| CDN asset hosting + cache busting | [cdn-assets.md](cdn-assets.md) |
| Step-by-step tutorial | [quickstart.md](quickstart.md) |
| Complete code examples | [examples.md](examples.md) |
The architecture follows this stack:
┌─────────────────────────────────────────────────────────────┐
│ ChatGPT / Claude │
├─────────────────────────────────────────────────────────────┤
│ ↓ SSE │
├─────────────────────────────────────────────────────────────┤
│ Cloudflare Worker (Edge) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ OAuthProvider │ │
│ │ - /authorize → oauth-handler.ts │ │
│ │ - /token → token exchange │ │
│ │ - /sse → McpAgent.mount() │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ McpAgent (Durable Object) │ │
│ │ - server: McpServer │ │
│ │ - props: auth data + slug params │ │
│ │ - state: SQLite persistence │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Tools + Resources │ │
│ │ - Widget tools → structuredContent │ │
│ │ - UI resources → text/html+skybridge │ │
│ │ - Static assets → /widgets/* │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
json{
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.0.5",
"@modelcontextprotocol/sdk": "^1.12.1",
"agents": "^0.0.82",
"hono": "^4.7.6",
"zod": "^3.24.2"
}
}
typescriptimport { McpAgent } from 'agents/mcp';
import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Hono } from 'hono';
import { z } from 'zod';
1. Client → GET /authorize?client_id=X&redirect_uri=Y&...
↓
2. OAuthProvider parses OAuth request
↓
3. oauth-handler.ts GET shows approval dialog
↓
4. User clicks "Authorize"
↓
5. oauth-handler.ts POST → completeAuthorization()
↓
6. OAuthProvider issues tokens, redirects to client
↓
7. Client → /sse with Bearer token
↓
8. McpAgent receives request with props from completeAuthorization()
typescriptimport { Hono } from 'hono';
const app = new Hono();
// GET /authorize - Show approval dialog
app.get('/authorize', async (c) => {
const oauthReq = c.get('oauthRequest'); // From OAuthProvider middleware
// Capture slug params from query string
const url = new URL(c.req.url);
const slugParams = {
cred: url.searchParams.get('cred'),
workspace: url.searchParams.get('workspace'),
};
// Render approval form (or auto-approve)
return c.html(`
<form method="POST" action="/authorize">
<input type="hidden" name="oauthRequest" value="${JSON.stringify(oauthReq)}" />
<input type="hidden" name="slugParams" value="${JSON.stringify(slugParams)}" />
<button type="submit">Authorize</button>
</form>
`);
});
// POST /authorize - Complete authorization
app.post('/authorize', async (c) => {
const body = await c.req.parseBody();
const oauthReq = JSON.parse(body.oauthRequest as string);
const slugParams = JSON.parse(body.slugParams as string);
// Complete OAuth flow with props
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
request: oauthReq,
userId: 'user-' + Date.now(),
props: {
authenticated: true,
...slugParams, // These become available in McpAgent.props
},
});
return Response.redirect(redirectTo);
});
export const oauthHandler = app;
typescriptconst oauthProvider = new OAuthProvider({
kvNamespace: 'OAUTH_KV', // KV for token storage
apiRoute: '/sse', // MCP endpoint
apiHandler: MyMCP.mount('/sse'), // McpAgent handler
defaultHandler: oauthHandler, // Routes /authorize here
authorizeEndpoint: '/authorize',
tokenEndpoint: '/token',
clientRegistrationEndpoint: '/register',
forceHTTPS: false,
lookupClient: async (clientId) => ({
id: clientId || 'default-client',
secret: 'client-secret',
allowed_redirects: ['*'],
name: 'MCP Client',
}),
});
typescriptinterface MyAuthProps extends Record<string, unknown> {
authenticated?: boolean;
userId?: string;
cred?: string;
workspace?: string;
}
interface MyState {
initialized?: boolean;
}
class MyMCP extends McpAgent<any, MyState, MyAuthProps> {
server = new McpServer({
name: 'my-mcp',
version: '1.0.0',
});
initialState: MyState = {};
async init() {
// Called once when Durable Object starts
await this.setupTools();
await this.setupResources();
}
private async setupTools() {
// Register tools using server.registerTool()
this.server.registerTool(
'my_tool',
{
title: 'My Tool',
description: 'Does something',
inputSchema: myToolSchema,
},
async (params) => {
// Access auth props: this.props.cred, this.props.workspace
return { content: [{ type: 'text', text: 'Result' }] };
}
);
}
private async setupResources() {
// Register resources using server.resource()
this.server.resource(
'my-resource',
'my://resource/uri',
{ title: 'My Resource', mimeType: 'text/html+skybridge' },
async (uri) => ({
contents: [{ uri: uri.href, mimeType: 'text/html+skybridge', text: '<html>...</html>' }],
})
);
}
}
export { MyMCP };
typescriptthis.server.registerTool('my_tool', schema, async (params) => {
// Props from completeAuthorization() are available here
const { cred, workspace, authenticated } = this.props;
if (!authenticated) {
return { content: [{ type: 'text', text: 'Not authenticated' }], isError: true };
}
// Use props for API calls, etc.
const result = await myService.fetch(workspace, params);
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
});
jsonc{
"durable_objects": {
"bindings": [
{
"name": "MCP_OBJECT",
"class_name": "MyMCP"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyMCP"]
}
]
}
typescript// Must export the class for Durable Object binding
export { MyMCP };
1. ChatGPT calls inbox_widget tool
↓
2. Worker receives request at /sse
↓
3. OAuthProvider validates Bearer token
↓
4. McpAgent.mount() routes to Durable Object
↓
5. Tool handler executes:
- Fetches data from backend
- Returns { content, structuredContent, _meta }
↓
6. _meta["openai/outputTemplate"] = "ui://mcp/list"
↓
7. ChatGPT fetches resource at ui://mcp/list
↓
8. Resource returns HTML with text/html+skybridge
↓
9. HTML loads external JS: /widgets/list.js
↓
10. JS reads window.openai.toolOutput (= structuredContent)
↓
11. Widget renders data inline in ChatGPT!
jsonc{
"account_id": "your-account-id",
"name": "my-mcp",
"main": "src/index.ts",
"compatibility_date": "2025-03-10",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [
{
"name": "MCP_OBJECT",
"class_name": "MyMCP"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyMCP"]
}
],
"kv_namespaces": [
{
"binding": "OAUTH_KV",
"id": "your-kv-namespace-id"
}
],
"vars": {
"ENVIRONMENT": "production"
}
}
bash# Create KV namespace for OAuth tokens
wrangler kv:namespace create "OAUTH_KV"
# Copy the ID to wrangler.jsonc
typescriptconst mainWorker = {
async fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// Landing page / health check
if (url.pathname === '/' || url.pathname === '/health') {
return new Response('OK', { status: 200 });
}
// Static widget assets with CORS
if (url.pathname.startsWith('/widgets/')) {
const asset = widgetAssets[url.pathname];
if (asset) {
return new Response(asset.content, {
headers: {
'Content-Type': asset.type,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=31536000',
},
});
}
return new Response('Not found', { status: 404 });
}
// Bypass OAuth for /sse (direct MCP access)
if (url.pathname === '/sse' || url.pathname.startsWith('/sse/')) {
return MyMCP.mount('/sse').fetch(request, env, ctx);
}
// All other routes through OAuth
return oauthProvider.fetch(request, env, ctx);
},
};
export default mainWorker;
# Architecture Deep Dive
## Table of Contents
- [Overview](#overview)
- [Cloudflare Workers Stack](#cloudflare-workers-stack)
- [OAuth2 PKCE Flow](#oauth2-pkce-flow)
- [McpAgent Pattern](#mcpagent-pattern)
- [Durable Objects](#durable-objects)
- [Request Flow](#request-flow)
- [wrangler.jsonc Configuration](#wranglerjsonc-configuration)
---
## Overview
The architecture follows this stack:
```
┌─────────────────────────────────────────────────────────────┐
│ ChatGPT / Claude │
├─────────────────────────────────────────────────────────────┤
│ ↓ SSE │
├─────────────────────────────────────────────────────────────┤
│ Cloudflare Worker (Edge) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ OAuthProvider │ │
│ │ - /authorize → oauth-handler.ts │ │
│ │ - /token → token exchange │ │
│ │ - /sse → McpAgent.mount() │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ McpAgent (Durable Object) │ │
│ │ - server: McpServer │ │
│ │ - props: auth data + slug params │ │
│ │ - state: SQLite persistence │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Tools + Resources │ │
│ │ - Widget tools → structuredContent │ │
│ │ - UI resources → text/html+skybridge │ │
│ │ - Static assets → /widgets/* │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## Cloudflare Workers Stack
### Dependencies
```json
{
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.0.5",
"@modelcontextprotocol/sdk": "^1.12.1",
"agents": "^0.0.82",
"hono": "^4.7.6",
"zod": "^3.24.2"
}
}
```
### Key Imports
```typescript
import { McpAgent } from 'agents/mcp';
import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Hono } from 'hono';
import { z } from 'zod';
```
---
## OAuth2 PKCE Flow
### Flow Diagram
```
1. Client → GET /authorize?client_id=X&redirect_uri=Y&...
↓
2. OAuthProvider parses OAuth request
↓
3. oauth-handler.ts GET shows approval dialog
↓
4. User clicks "Authorize"
↓
5. oauth-handler.ts POST → completeAuthorization()
↓
6. OAuthProvider issues tokens, redirects to client
↓
7. Client → /sse with Bearer token
↓
8. McpAgent receives request with props from completeAuthorization()
```
### oauth-handler.ts Implementation
```typescript
import { Hono } from 'hono';
const app = new Hono();
// GET /authorize - Show approval dialog
app.get('/authorize', async (c) => {
const oauthReq = c.get('oauthRequest'); // From OAuthProvider middleware
// Capture slug params from query string
const url = new URL(c.req.url);
const slugParams = {
cred: url.searchParams.get('cred'),
workspace: url.searchParams.get('workspace'),
};
// Render approval form (or auto-approve)
return c.html(`
<form method="POST" action="/authorize">
<input type="hidden" name="oauthRequest" value="${JSON.stringify(oauthReq)}" />
<input type="hidden" name="slugParams" value="${JSON.stringify(slugParams)}" />
<button type="submit">Authorize</button>
</form>
`);
});
// POST /authorize - Complete authorization
app.post('/authorize', async (c) => {
const body = await c.req.parseBody();
const oauthReq = JSON.parse(body.oauthRequest as string);
const slugParams = JSON.parse(body.slugParams as string);
// Complete OAuth flow with props
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
request: oauthReq,
userId: 'user-' + Date.now(),
props: {
authenticated: true,
...slugParams, // These become available in McpAgent.props
},
});
return Response.redirect(redirectTo);
});
export const oauthHandler = app;
```
### OAuthProvider Configuration
```typescript
const oauthProvider = new OAuthProvider({
kvNamespace: 'OAUTH_KV', // KV for token storage
apiRoute: '/sse', // MCP endpoint
apiHandler: MyMCP.mount('/sse'), // McpAgent handler
defaultHandler: oauthHandler, // Routes /authorize here
authorizeEndpoint: '/authorize',
tokenEndpoint: '/token',
clientRegistrationEndpoint: '/register',
forceHTTPS: false,
lookupClient: async (clientId) => ({
id: clientId || 'default-client',
secret: 'client-secret',
allowed_redirects: ['*'],
name: 'MCP Client',
}),
});
```
---
## McpAgent Pattern
### Class Structure
```typescript
interface MyAuthProps extends Record<string, unknown> {
authenticated?: boolean;
userId?: string;
cred?: string;
workspace?: string;
}
interface MyState {
initialized?: boolean;
}
class MyMCP extends McpAgent<any, MyState, MyAuthProps> {
server = new McpServer({
name: 'my-mcp',
version: '1.0.0',
});
initialState: MyState = {};
async init() {
// Called once when Durable Object starts
await this.setupTools();
await this.setupResources();
}
private async setupTools() {
// Register tools using server.registerTool()
this.server.registerTool(
'my_tool',
{
title: 'My Tool',
description: 'Does something',
inputSchema: myToolSchema,
},
async (params) => {
// Access auth props: this.props.cred, this.props.workspace
return { content: [{ type: 'text', text: 'Result' }] };
}
);
}
private async setupResources() {
// Register resources using server.resource()
this.server.resource(
'my-resource',
'my://resource/uri',
{ title: 'My Resource', mimeType: 'text/html+skybridge' },
async (uri) => ({
contents: [{ uri: uri.href, mimeType: 'text/html+skybridge', text: '<html>...</html>' }],
})
);
}
}
export { MyMCP };
```
### Accessing Props in Tools
```typescript
this.server.registerTool('my_tool', schema, async (params) => {
// Props from completeAuthorization() are available here
const { cred, workspace, authenticated } = this.props;
if (!authenticated) {
return { content: [{ type: 'text', text: 'Not authenticated' }], isError: true };
}
// Use props for API calls, etc.
const result = await myService.fetch(workspace, params);
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
});
```
---
## Durable Objects
### What They Do
- Provide persistent state per user/session
- Run in a single location (consistent state)
- Survive Worker restarts
- Store SQLite data (via McpAgent)
### Configuration in wrangler.jsonc
```jsonc
{
"durable_objects": {
"bindings": [
{
"name": "MCP_OBJECT",
"class_name": "MyMCP"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyMCP"]
}
]
}
```
### Export the Class
```typescript
// Must export the class for Durable Object binding
export { MyMCP };
```
---
## Request Flow
### Complete Flow with Widget
```
1. ChatGPT calls inbox_widget tool
↓
2. Worker receives request at /sse
↓
3. OAuthProvider validates Bearer token
↓
4. McpAgent.mount() routes to Durable Object
↓
5. Tool handler executes:
- Fetches data from backend
- Returns { content, structuredContent, _meta }
↓
6. _meta["openai/outputTemplate"] = "ui://mcp/list"
↓
7. ChatGPT fetches resource at ui://mcp/list
↓
8. Resource returns HTML with text/html+skybridge
↓
9. HTML loads external JS: /widgets/list.js
↓
10. JS reads window.openai.toolOutput (= structuredContent)
↓
11. Widget renders data inline in ChatGPT!
```
---
## wrangler.jsonc Configuration
### Complete Example
```jsonc
{
"account_id": "your-account-id",
"name": "my-mcp",
"main": "src/index.ts",
"compatibility_date": "2025-03-10",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [
{
"name": "MCP_OBJECT",
"class_name": "MyMCP"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyMCP"]
}
],
"kv_namespaces": [
{
"binding": "OAUTH_KV",
"id": "your-kv-namespace-id"
}
],
"vars": {
"ENVIRONMENT": "production"
}
}
```
### Creating KV Namespace
```bash
# Create KV namespace for OAuth tokens
wrangler kv:namespace create "OAUTH_KV"
# Copy the ID to wrangler.jsonc
```
---
## Main Worker Export
```typescript
const mainWorker = {
async fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// Landing page / health check
if (url.pathname === '/' || url.pathname === '/health') {
return new Response('OK', { status: 200 });
}
// Static widget assets with CORS
if (url.pathname.startsWith('/widgets/')) {
const asset = widgetAssets[url.pathname];
if (asset) {
return new Response(asset.content, {
headers: {
'Content-Type': asset.type,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=31536000',
},
});
}
return new Response('Not found', { status: 404 });
}
// Bypass OAuth for /sse (direct MCP access)
if (url.pathname === '/sse' || url.pathname.startsWith('/sse/')) {
return MyMCP.mount('/sse').fetch(request, env, ctx);
}
// All other routes through OAuth
return oauthProvider.fetch(request, env, ctx);
},
};
export default mainWorker;
```
---
## Next Steps
- See [widgets.md](widgets.md) for implementing ChatGPT Apps SDK widgets
- See [cdn-assets.md](cdn-assets.md) for hosting widget assets
- See [quickstart.md](quickstart.md) for building from scratch
ChatGPT's sandbox blocks inline scripts. This is a critical security measure that affects how widgets work.
html<!-- ❌ BLOCKED - Inline scripts won't execute -->
<script>
var data = window.openai.toolOutput;
console.log(data); // Never runs!
</script>
html<!-- ✅ WORKS - External scripts are allowed -->
<script src="https://mymcp.workers.dev/widgets/list.js"></script>
Key insight: Your widget JS/CSS must be served from an external URL that ChatGPT's sandbox can access.
Resource returns HTML:
<div id="root"></div>
<link rel="stylesheet" href="https://mcp.example.com/static/widgets/pizza/main.css">
<script src="https://mcp.example.com/static/widgets/pizza/main.js"></script>
typescript// Resource returns minimal HTML pointing to CDN
return `<div id="widget-list-root"></div>
<link rel="stylesheet" href="https://my-mcp.robertjboulos.workers.dev/widgets/my-mcp-list.css">
<script src="https://my-mcp.robertjboulos.workers.dev/widgets/my-mcp-list.js"></script>`;
Store widget JS/CSS as TypeScript string constants, serve from same Worker.
typescript// src/widgets/assets.ts
export const WIDGET_LIST_JS = `
(function() {
'use strict';
var root = document.getElementById('widget-list-root');
var toolOutput = window.openai ? window.openai.toolOutput : null;
// ... render logic
})();
`.trim();
export const WIDGET_LIST_CSS = `
* { box-sizing: border-box; }
body { font-family: system-ui; }
.my-mcp-item { display: flex; gap: 12px; }
// ... styles
`.trim();
Store in /public/widgets/ directory, configure Wrangler to serve.
public/
└── widgets/
├── list.js
├── list.css
├── calendar.js
└── calendar.css
jsonc// wrangler.jsonc
{
"site": {
"bucket": "./public"
}
}
Critical: Widget assets MUST have proper CORS headers for ChatGPT's sandbox.
typescriptconst corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
typescript// In src/index.ts
const widgetAssets: Record<string, { content: string; type: string }> = {
'/widgets/my-mcp-list.js': {
content: WIDGET_LIST_JS,
type: 'application/javascript',
},
'/widgets/my-mcp-list.css': {
content: WIDGET_LIST_CSS,
type: 'text/css',
},
};
// In fetch handler
if (url.pathname.startsWith('/widgets/')) {
// Handle CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
const asset = widgetAssets[url.pathname];
if (asset) {
return new Response(asset.content, {
headers: {
'Content-Type': asset.type,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=31536000',
},
});
}
return new Response('Not found', { status: 404 });
}
src/
├── widgets/
│ └── assets.ts ← All widget JS/CSS as string constants
├── resources/
│ └── ui.ts ← Resources that point to assets
└── index.ts ← Serves /widgets/* endpoints
/widgets/{widget-name}.js
/widgets/{widget-name}.css
Examples:
/widgets/my-mcp-list.js/widgets/my-mcp-list.css/widgets/my-mcp-calendar.js/widgets/my-mcp-calendar.csstypescript// src/widgets/assets.ts
// List widget
export const WIDGET_LIST_JS = `...`.trim();
export const WIDGET_LIST_CSS = `...`.trim();
// Calendar widget
export const WIDGET_CALENDAR_JS = `...`.trim();
export const WIDGET_CALENDAR_CSS = `...`.trim();
// Stats widget
export const WIDGET_STATS_JS = `...`.trim();
export const WIDGET_STATS_CSS = `...`.trim();
Widget assets rarely change, so cache aggressively:
typescript'Cache-Control': 'public, max-age=31536000' // 1 year
Use a version constant - centralized, easy to bump, clear history.
typescript// src/resources/ui.ts
// =====================================================
// CACHE BUSTER VERSION
// Increment on EVERY deploy that changes widget assets
// =====================================================
const WIDGET_VERSION = 'v5';
const WIDGET_ASSETS_BASE = 'https://mymcp.workers.dev';
/**
* Get HTML template for a widget with cache-busted asset URLs
*/
function getWidgetHtml(widgetId: string, rootId: string): string {
const cacheBuster = `?${WIDGET_VERSION}`;
return `<div id="${rootId}"></div>
<link rel="stylesheet" href="${WIDGET_ASSETS_BASE}/widgets/${widgetId}.css${cacheBuster}">
<script src="${WIDGET_ASSETS_BASE}/widgets/${widgetId}.js${cacheBuster}"></script>`;
}
When to bump: ANY change to widget JS or CSS
typescript// src/resources/ui.ts - Version history comment pattern
// =====================================================
// VERSION HISTORY (keep last 5 entries)
// v1: Initial release
// v2: Added reply inline
// v3: Fixed compose flow
// v4: Added compact mode
// v5: Pre-loaded schemas, CSS Grid animation
// =====================================================
const WIDGET_VERSION = 'v5';
Workflow:
assets.tsui.ts: v5 → v6npm run build && npm run deploy| Approach | Pros | Cons |
|---|---|---|
| Version constant (recommended) | Centralized, clear history, easy debug | Manual bump required |
| Version in filename | Works | Must update all references |
| Content hash | Automatic | Hard to debug, complex setup |
| No cache busting | Simple | Users stuck on old versions! |
If users report seeing old widgets after deploy:
bash# 1. Check current version
grep "WIDGET_VERSION" src/resources/ui.ts
# 2. Bump it
sed -i '' "s/WIDGET_VERSION = 'v5'/WIDGET_VERSION = 'v6'/" src/resources/ui.ts
# 3. Redeploy
npm run build && npm run deploy
typescript// src/widgets/assets.ts
export const WIDGET_LIST_JS = `
(function() {
'use strict';
var root = document.getElementById('widget-list-root');
if (!root) {
console.error('[Widget] Root element not found');
return;
}
var toolOutput = window.openai ? window.openai.toolOutput : null;
console.log('[Widget] toolOutput:', toolOutput);
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data available</div>';
return;
}
var data = toolOutput.structuredContent || toolOutput;
var items = data.items || [];
var html = '<div class="header">' + escapeHtml(data.title || 'Items') + '</div>';
html += '<div class="items">';
for (var i = 0; i < items.length; i++) {
var item = items[i];
html += '<div class="item">';
html += '<div class="icon">' + (item.icon || '📄') + '</div>';
html += '<div class="title">' + escapeHtml(item.title || '') + '</div>';
html += '</div>';
}
html += '</div>';
root.innerHTML = html;
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
})();
`.trim();
export const WIDGET_LIST_CSS = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui; padding: 16px; }
.header { font-size: 18px; font-weight: 600; margin-bottom: 16px; }
.items { max-height: 400px; overflow-y: auto; }
.item { display: flex; gap: 12px; padding: 12px; border-bottom: 1px solid #e5e7eb; }
.item:hover { background: #f9fafb; }
.icon { font-size: 24px; }
.title { font-weight: 500; }
.empty { text-align: center; color: #9ca3af; padding: 32px; }
`.trim();
typescriptimport { WIDGET_LIST_JS, WIDGET_LIST_CSS } from './widgets/assets';
const widgetAssets: Record<string, { content: string; type: string }> = {
'/widgets/my-mcp-list.js': { content: WIDGET_LIST_JS, type: 'application/javascript' },
'/widgets/my-mcp-list.css': { content: WIDGET_LIST_CSS, type: 'text/css' },
};
// In fetch handler, before OAuth routing
if (url.pathname.startsWith('/widgets/')) {
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
const asset = widgetAssets[url.pathname];
if (asset) {
return new Response(asset.content, {
headers: {
'Content-Type': asset.type,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=31536000',
},
});
}
return new Response('Not found', { status: 404 });
}
typescript// src/resources/ui.ts
const ASSETS_BASE = 'https://mymcp.workers.dev';
function getDynamicWidgetHtml(widgetId: string): string {
switch (widgetId) {
case 'list':
return `<div id="widget-list-root"></div>
<link rel="stylesheet" href="${ASSETS_BASE}/widgets/my-mcp-list.css">
<script src="${ASSETS_BASE}/widgets/my-mcp-list.js"></script>`;
// ... other widgets
}
}
bash# Build and deploy
npm run build && npm run deploy
# Test assets are served
curl https://mymcp.workers.dev/widgets/my-mcp-list.js
curl https://mymcp.workers.dev/widgets/my-mcp-list.css
# Check CORS headers
curl -I https://mymcp.workers.dev/widgets/my-mcp-list.js
# Should show: Access-Control-Allow-Origin: *
window.openai.toolOutput is nullstructuredContent_meta["openai/outputTemplate"] matches resource URIcurl <url> and look for syntax errors(function() { ... })();console.log('[Widget]', ...) for debuggingwrangler dev before deploying# CDN-Hosted Widget Assets
## Table of Contents
- [Why External Assets](#why-external-assets)
- [The Assets Pattern](#the-assets-pattern)
- [Serving from Workers](#serving-from-workers)
- [CORS Configuration](#cors-configuration)
- [Asset Organization](#asset-organization)
- [Caching Strategy](#caching-strategy)
- [Complete Implementation](#complete-implementation)
---
## Why External Assets
**ChatGPT's sandbox blocks inline scripts.** This is a critical security measure that affects how widgets work.
### What Doesn't Work
```html
<!-- ❌ BLOCKED - Inline scripts won't execute -->
<script>
var data = window.openai.toolOutput;
console.log(data); // Never runs!
</script>
```
### What Works
```html
<!-- ✅ WORKS - External scripts are allowed -->
<script src="https://mymcp.workers.dev/widgets/list.js"></script>
```
**Key insight**: Your widget JS/CSS must be served from an external URL that ChatGPT's sandbox can access.
---
## The Assets Pattern
### From the Xano Pizza Example
```
Resource returns HTML:
<div id="root"></div>
<link rel="stylesheet" href="https://mcp.example.com/static/widgets/pizza/main.css">
<script src="https://mcp.example.com/static/widgets/pizza/main.js"></script>
```
### your MCP Implementation
```typescript
// Resource returns minimal HTML pointing to CDN
return `<div id="widget-list-root"></div>
<link rel="stylesheet" href="https://my-mcp.robertjboulos.workers.dev/widgets/my-mcp-list.css">
<script src="https://my-mcp.robertjboulos.workers.dev/widgets/my-mcp-list.js"></script>`;
```
---
## Serving from Workers
### Option 1: String Constants (Recommended)
Store widget JS/CSS as TypeScript string constants, serve from same Worker.
```typescript
// src/widgets/assets.ts
export const WIDGET_LIST_JS = `
(function() {
'use strict';
var root = document.getElementById('widget-list-root');
var toolOutput = window.openai ? window.openai.toolOutput : null;
// ... render logic
})();
`.trim();
export const WIDGET_LIST_CSS = `
* { box-sizing: border-box; }
body { font-family: system-ui; }
.my-mcp-item { display: flex; gap: 12px; }
// ... styles
`.trim();
```
### Option 2: Static Files (Alternative)
Store in `/public/widgets/` directory, configure Wrangler to serve.
```
public/
└── widgets/
├── list.js
├── list.css
├── calendar.js
└── calendar.css
```
```jsonc
// wrangler.jsonc
{
"site": {
"bucket": "./public"
}
}
```
---
## CORS Configuration
**Critical**: Widget assets MUST have proper CORS headers for ChatGPT's sandbox.
### Required Headers
```typescript
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
```
### Complete Asset Route Handler
```typescript
// In src/index.ts
const widgetAssets: Record<string, { content: string; type: string }> = {
'/widgets/my-mcp-list.js': {
content: WIDGET_LIST_JS,
type: 'application/javascript',
},
'/widgets/my-mcp-list.css': {
content: WIDGET_LIST_CSS,
type: 'text/css',
},
};
// In fetch handler
if (url.pathname.startsWith('/widgets/')) {
// Handle CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
const asset = widgetAssets[url.pathname];
if (asset) {
return new Response(asset.content, {
headers: {
'Content-Type': asset.type,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=31536000',
},
});
}
return new Response('Not found', { status: 404 });
}
```
---
## Asset Organization
### Directory Structure
```
src/
├── widgets/
│ └── assets.ts ← All widget JS/CSS as string constants
├── resources/
│ └── ui.ts ← Resources that point to assets
└── index.ts ← Serves /widgets/* endpoints
```
### Naming Convention
```
/widgets/{widget-name}.js
/widgets/{widget-name}.css
```
Examples:
- `/widgets/my-mcp-list.js`
- `/widgets/my-mcp-list.css`
- `/widgets/my-mcp-calendar.js`
- `/widgets/my-mcp-calendar.css`
### Asset File Pattern
```typescript
// src/widgets/assets.ts
// List widget
export const WIDGET_LIST_JS = `...`.trim();
export const WIDGET_LIST_CSS = `...`.trim();
// Calendar widget
export const WIDGET_CALENDAR_JS = `...`.trim();
export const WIDGET_CALENDAR_CSS = `...`.trim();
// Stats widget
export const WIDGET_STATS_JS = `...`.trim();
export const WIDGET_STATS_CSS = `...`.trim();
```
---
## Caching Strategy
### Long-Term Caching
Widget assets rarely change, so cache aggressively:
```typescript
'Cache-Control': 'public, max-age=31536000' // 1 year
```
### Cache Busting (The Right Way)
**Use a version constant** - centralized, easy to bump, clear history.
```typescript
// src/resources/ui.ts
// =====================================================
// CACHE BUSTER VERSION
// Increment on EVERY deploy that changes widget assets
// =====================================================
const WIDGET_VERSION = 'v5';
const WIDGET_ASSETS_BASE = 'https://mymcp.workers.dev';
/**
* Get HTML template for a widget with cache-busted asset URLs
*/
function getWidgetHtml(widgetId: string, rootId: string): string {
const cacheBuster = `?${WIDGET_VERSION}`;
return `<div id="${rootId}"></div>
<link rel="stylesheet" href="${WIDGET_ASSETS_BASE}/widgets/${widgetId}.css${cacheBuster}">
<script src="${WIDGET_ASSETS_BASE}/widgets/${widgetId}.js${cacheBuster}"></script>`;
}
```
### Version Bump Workflow
**When to bump**: ANY change to widget JS or CSS
```typescript
// src/resources/ui.ts - Version history comment pattern
// =====================================================
// VERSION HISTORY (keep last 5 entries)
// v1: Initial release
// v2: Added reply inline
// v3: Fixed compose flow
// v4: Added compact mode
// v5: Pre-loaded schemas, CSS Grid animation
// =====================================================
const WIDGET_VERSION = 'v5';
```
**Workflow:**
1. Make changes to widget JS/CSS in `assets.ts`
2. Bump version in `ui.ts`: `v5` → `v6`
3. `npm run build && npm run deploy`
4. Users get fresh assets immediately
### Why Version Constants Beat Other Approaches
| Approach | Pros | Cons |
|----------|------|------|
| **Version constant (recommended)** | Centralized, clear history, easy debug | Manual bump required |
| Version in filename | Works | Must update all references |
| Content hash | Automatic | Hard to debug, complex setup |
| No cache busting | Simple | Users stuck on old versions! |
### Emergency Cache Bust
If users report seeing old widgets after deploy:
```bash
# 1. Check current version
grep "WIDGET_VERSION" src/resources/ui.ts
# 2. Bump it
sed -i '' "s/WIDGET_VERSION = 'v5'/WIDGET_VERSION = 'v6'/" src/resources/ui.ts
# 3. Redeploy
npm run build && npm run deploy
```
---
## Complete Implementation
### 1. Create assets.ts
```typescript
// src/widgets/assets.ts
export const WIDGET_LIST_JS = `
(function() {
'use strict';
var root = document.getElementById('widget-list-root');
if (!root) {
console.error('[Widget] Root element not found');
return;
}
var toolOutput = window.openai ? window.openai.toolOutput : null;
console.log('[Widget] toolOutput:', toolOutput);
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data available</div>';
return;
}
var data = toolOutput.structuredContent || toolOutput;
var items = data.items || [];
var html = '<div class="header">' + escapeHtml(data.title || 'Items') + '</div>';
html += '<div class="items">';
for (var i = 0; i < items.length; i++) {
var item = items[i];
html += '<div class="item">';
html += '<div class="icon">' + (item.icon || '📄') + '</div>';
html += '<div class="title">' + escapeHtml(item.title || '') + '</div>';
html += '</div>';
}
html += '</div>';
root.innerHTML = html;
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
})();
`.trim();
export const WIDGET_LIST_CSS = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui; padding: 16px; }
.header { font-size: 18px; font-weight: 600; margin-bottom: 16px; }
.items { max-height: 400px; overflow-y: auto; }
.item { display: flex; gap: 12px; padding: 12px; border-bottom: 1px solid #e5e7eb; }
.item:hover { background: #f9fafb; }
.icon { font-size: 24px; }
.title { font-weight: 500; }
.empty { text-align: center; color: #9ca3af; padding: 32px; }
`.trim();
```
### 2. Register asset routes in index.ts
```typescript
import { WIDGET_LIST_JS, WIDGET_LIST_CSS } from './widgets/assets';
const widgetAssets: Record<string, { content: string; type: string }> = {
'/widgets/my-mcp-list.js': { content: WIDGET_LIST_JS, type: 'application/javascript' },
'/widgets/my-mcp-list.css': { content: WIDGET_LIST_CSS, type: 'text/css' },
};
// In fetch handler, before OAuth routing
if (url.pathname.startsWith('/widgets/')) {
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
const asset = widgetAssets[url.pathname];
if (asset) {
return new Response(asset.content, {
headers: {
'Content-Type': asset.type,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=31536000',
},
});
}
return new Response('Not found', { status: 404 });
}
```
### 3. Update resource to point to assets
```typescript
// src/resources/ui.ts
const ASSETS_BASE = 'https://mymcp.workers.dev';
function getDynamicWidgetHtml(widgetId: string): string {
switch (widgetId) {
case 'list':
return `<div id="widget-list-root"></div>
<link rel="stylesheet" href="${ASSETS_BASE}/widgets/my-mcp-list.css">
<script src="${ASSETS_BASE}/widgets/my-mcp-list.js"></script>`;
// ... other widgets
}
}
```
### 4. Verify it works
```bash
# Build and deploy
npm run build && npm run deploy
# Test assets are served
curl https://mymcp.workers.dev/widgets/my-mcp-list.js
curl https://mymcp.workers.dev/widgets/my-mcp-list.css
# Check CORS headers
curl -I https://mymcp.workers.dev/widgets/my-mcp-list.js
# Should show: Access-Control-Allow-Origin: *
```
---
## Troubleshooting
### Widget shows "Loading..." forever
- Check browser console for 404 on asset URLs
- Verify assets are deployed and accessible
- Check CORS headers are present
### Widget shows but no data
- Asset loaded but `window.openai.toolOutput` is null
- Check tool returns `structuredContent`
- Check `_meta["openai/outputTemplate"]` matches resource URI
### Styles not applying
- CSS file not loading (check network tab)
- Wrong selector names in CSS vs HTML
- CSS blocked by CSP (unlikely for external CSS)
### JavaScript errors in widget
- Check browser console in ChatGPT
- Test JS file directly: `curl <url>` and look for syntax errors
- Ensure IIFE wrapper: `(function() { ... })();`
---
## Best Practices
1. **Use IIFE wrapper** - Prevents global scope pollution
2. **Defensive coding** - Always check if elements/data exist
3. **Escape HTML** - Prevent XSS from user data
4. **Log everything** - `console.log('[Widget]', ...)` for debugging
5. **Cache aggressively** - Widget assets rarely change
6. **Test locally first** - Use `wrangler dev` before deploying
---
## Next Steps
- See [widgets.md](widgets.md) for complete widget tool implementation
- See [quickstart.md](quickstart.md) for building from scratch
- See [examples.md](examples.md) for more widget types
ChatGPT is the interface. Your widget enhances the conversation.
"Apps extend what users can do without breaking the flow of conversation."
— OpenAI Apps SDK Guidelines
| Traditional App | ChatGPT Widget |
|---|---|
| User navigates through UI | User converses, widget appears contextually |
| Complex multi-step flows | Single-purpose, focused interactions |
| Full feature parity | Conversation-relevant subset |
| User controls everything | AI + User collaborate |
| Standalone experience | Embedded in conversation flow |
❌ WRONG: "Let me build the full Xano dashboard as a widget"
- Table browser with infinite scroll
- API editor with full code editing
- Settings panel with all configuration options
- Navigation sidebar with 20 sections
Why it fails:
❌ WRONG:
- Text input fields that replicate the composer
- "Send message" buttons
- Chat-like interfaces within the widget
- AI response rendering
Why: ChatGPT already does this. Let the composer handle user input.
❌ WRONG:
- Sidebar navigation
- Tab bars with 5+ tabs
- Breadcrumb trails
- Back/forward history
- Multi-step wizards
Why: Each widget should be single-purpose. Need more depth? Spawn a new tool call.
✅ RIGHT: "Here's what matters right now"
- 5 tables with the most records
- 3 endpoints that failed today
- Today's calendar at a glance
- Key metrics dashboard
✅ RIGHT: "Help me do this one thing"
- Approve/Reject with one click
- Quick edit inline
- Toggle a setting
- Confirm a deletion
✅ RIGHT: "Help me understand so I can decide"
- Side-by-side comparison
- Before/after preview
- Risk indicators
- Related items
✅ RIGHT: "Show me what the AI is talking about"
- Schema visualization for "the users table"
- Email preview for "that message from John"
- Map for "the office location"
Design widgets as visual aids to the conversation, not replacements for it.
❌ 1:1 Clone Approach:
┌─────────────────────────────────────────┐
│ [Sidebar] [Table List] [Schema Editor] │
│ users │ id │ name │ email │ ... │
│ posts │ Add Field │ Edit │ Delete │
│ comments │ [Full CRUD interface] │
│ orders │ [Inline editing] │
│ products │ [Complex filters] │
│ invoices │ [Pagination controls] │
│ settings │ [Export options] │
└─────────────────────────────────────────┘
✅ Conversation-First Approach:
┌─────────────────────────────────────────┐
│ 📊 Database Overview │
│ 47 tables • 2.3M total records │
├─────────────────────────────────────────┤
│ ▸ users (42K) ▸ transactions (1.2M)│
│ ▸ products (2.3K) ▸ orders (156K) │
│ │
│ Click any table for details, or ask: │
│ "Show me the users schema" │
│ "Which tables have the most records?" │
└─────────────────────────────────────────┘
The second approach:
"Limit primary actions per card: Support up to two actions maximum"
✅ GOOD: Two clear actions
┌─────────────────────────────────────────┐
│ 📧 Email from John Doe │
│ "Can we reschedule our meeting?" │
│ │
│ [Reply] [Archive] │
└─────────────────────────────────────────┘
❌ BAD: Too many options
┌─────────────────────────────────────────┐
│ 📧 Email from John Doe │
│ "Can we reschedule our meeting?" │
│ │
│ [Reply] [Forward] [Archive] [Delete] │
│ [Mark Unread] [Add Label] [Move] │
└─────────────────────────────────────────┘
javascript// Action completes in-widget (callTool)
archiveBtn.addEventListener('click', function() {
window.openai.callTool('archive_email', { id: emailId })
.then(function() {
showToast('Archived');
// Widget updates inline
});
});
// Action continues conversation (sendFollowUpMessage)
replyBtn.addEventListener('click', function() {
window.openai.sendFollowUpMessage({
prompt: 'I want to reply to this email from John about rescheduling'
});
// AI takes over to help draft reply
});
When: User needs to scan/understand data
┌─────────────────────────────────────────┐
│ 📊 API Health Dashboard │
├─────────────────────────────────────────┤
│ /api/users ✓ 45ms 99.9% uptime │
│ /api/orders ✓ 120ms 99.7% uptime │
│ /api/payments ⚠ 890ms 98.2% uptime │
│ /api/reports ✓ 230ms 99.5% uptime │
├─────────────────────────────────────────┤
│ 4 endpoints • 1 warning • Avg 321ms │
└─────────────────────────────────────────┘
When: User needs to make decisions/changes
┌─────────────────────────────────────────┐
│ 🔀 Merge Duplicates? │
│ │
│ "Rob B" (12 interactions) │
│ └── Likely same as ──┘ │
│ "Robert Boulos" (45 interactions) │
│ │
│ [Merge into Robert] [Keep Separate] │
└─────────────────────────────────────────┘
USE FOR:
- Quick confirmations
- Single data points
- Status updates
- Simple actions (1-2 buttons)
EXAMPLES:
- "Order #1234 shipped ✓"
- "Meeting confirmed for 3pm"
- "Table 'users' has 42,000 records"
USE FOR:
- 3-8 similar items
- Items with images/icons
- Browse and select patterns
EXAMPLES:
- Restaurant recommendations
- Product options
- Recent documents
USE FOR:
- Rich exploration (maps, diagrams)
- Multi-step workflows
- Content that needs space
EXAMPLES:
- Interactive database schema diagram
- Document editor with preview
- Calendar week view
REMEMBER: Composer is always visible!
USE FOR:
- Ongoing sessions
- Reactions to chat input
- Persistent state during conversation
EXAMPLES:
- Live quiz game
- Collaborative editing
- Video playback
Before (1:1 Clone):
- Full table list with sorting
- Inline record editing
- Schema modification
- Import/export buttons
- Filter builder
- Pagination with page numbers
After (Conversation-First):
Widget shows:
- Top 10 tables by record count
- Click to expand schema (pre-loaded)
- Visual indicators (auth, record count)
Conversation handles:
- "Edit the users table schema"
- "Export transactions to CSV"
- "Show tables with more than 10K records"
- "Add a new field to orders"
Before (1:1 Clone):
- Full inbox with all emails
- Compose window
- Folder navigation
- Search with filters
- Rich text editor
- Attachment handling
After (Conversation-First):
Widget shows:
- 5 most recent/important emails
- Quick reply/archive actions
- Read/unread indicators
Conversation handles:
- "Draft a reply to John"
- "Find emails from last week about invoices"
- "Archive all newsletters"
- "Forward this to the team"
Before (1:1 Clone):
- Full endpoint list
- Request builder
- Response viewer
- Headers editor
- Authentication config
- Documentation panel
After (Conversation-First):
Widget shows:
- Endpoint groups overview
- Health status indicators
- Recent failures highlighted
Conversation handles:
- "Test the /api/users endpoint"
- "Show me the request history"
- "Why is payments slow?"
- "Add authentication to this endpoint"
If YES → Let ChatGPT do it
- Text generation
- Data analysis
- Decision making
- Complex queries
If NO → Widget should handle it
- Visual display
- Quick actions
- Real-time data
- Interactive selection
If YES → Inline card
- Status, count, simple list
If NEEDS_SCAN → Inline carousel or compact list
- Multiple similar items
If NEEDS_EXPLORATION → Fullscreen
- Maps, diagrams, rich content
Every widget should answer: "What's the one thing the user can DO here?"
- NOT: "Everything they could do in our app"
- YES: "See their tables and click to explore"
- YES: "Approve or reject this request"
- YES: "Understand the schema structure"
After the widget action:
- Does conversation continue? → sendFollowUpMessage
- Does data update? → callTool
- Is it done? → Toast/confirmation
Before building a widget, ask:
# Conversation-First Design - Not a 1:1 Clone
## Table of Contents
- [The Core Philosophy](#the-core-philosophy)
- [What NOT To Build](#what-not-to-build)
- [What TO Build](#what-to-build)
- [The Conversation Partner Pattern](#the-conversation-partner-pattern)
- [Action-Oriented Widgets](#action-oriented-widgets)
- [Information Density vs Interaction Depth](#information-density-vs-interaction-depth)
- [When to Use Each Display Mode](#when-to-use-each-display-mode)
- [Practical Examples](#practical-examples)
- [Design Decision Framework](#design-decision-framework)
---
## The Core Philosophy
**ChatGPT is the interface. Your widget enhances the conversation.**
> "Apps extend what users can do without breaking the flow of conversation."
> — OpenAI Apps SDK Guidelines
### The Mindset Shift
| Traditional App | ChatGPT Widget |
|-----------------|----------------|
| User navigates through UI | User converses, widget appears contextually |
| Complex multi-step flows | Single-purpose, focused interactions |
| Full feature parity | Conversation-relevant subset |
| User controls everything | AI + User collaborate |
| Standalone experience | Embedded in conversation flow |
---
## What NOT To Build
### Don't: Replicate Your Full Platform
```
❌ WRONG: "Let me build the full Xano dashboard as a widget"
- Table browser with infinite scroll
- API editor with full code editing
- Settings panel with all configuration options
- Navigation sidebar with 20 sections
```
Why it fails:
- **Too complex** - Users came to chat, not use your app
- **Fighting the system** - Composer is always there, embrace it
- **Wasted effort** - Users can just open your actual app
- **Poor UX** - Nested scrolling, cramped controls, broken accessibility
### Don't: Duplicate ChatGPT Features
```
❌ WRONG:
- Text input fields that replicate the composer
- "Send message" buttons
- Chat-like interfaces within the widget
- AI response rendering
```
Why: ChatGPT already does this. Let the composer handle user input.
### Don't: Build Deep Navigation
```
❌ WRONG:
- Sidebar navigation
- Tab bars with 5+ tabs
- Breadcrumb trails
- Back/forward history
- Multi-step wizards
```
Why: Each widget should be single-purpose. Need more depth? Spawn a new tool call.
---
## What TO Build
### Build: Glanceable Summaries
```
✅ RIGHT: "Here's what matters right now"
- 5 tables with the most records
- 3 endpoints that failed today
- Today's calendar at a glance
- Key metrics dashboard
```
### Build: Action Enablers
```
✅ RIGHT: "Help me do this one thing"
- Approve/Reject with one click
- Quick edit inline
- Toggle a setting
- Confirm a deletion
```
### Build: Decision Support
```
✅ RIGHT: "Help me understand so I can decide"
- Side-by-side comparison
- Before/after preview
- Risk indicators
- Related items
```
### Build: Context Enhancers
```
✅ RIGHT: "Show me what the AI is talking about"
- Schema visualization for "the users table"
- Email preview for "that message from John"
- Map for "the office location"
```
---
## The Conversation Partner Pattern
**Design widgets as visual aids to the conversation, not replacements for it.**
### Example: Database Explorer
```
❌ 1:1 Clone Approach:
┌─────────────────────────────────────────┐
│ [Sidebar] [Table List] [Schema Editor] │
│ users │ id │ name │ email │ ... │
│ posts │ Add Field │ Edit │ Delete │
│ comments │ [Full CRUD interface] │
│ orders │ [Inline editing] │
│ products │ [Complex filters] │
│ invoices │ [Pagination controls] │
│ settings │ [Export options] │
└─────────────────────────────────────────┘
```
```
✅ Conversation-First Approach:
┌─────────────────────────────────────────┐
│ 📊 Database Overview │
│ 47 tables • 2.3M total records │
├─────────────────────────────────────────┤
│ ▸ users (42K) ▸ transactions (1.2M)│
│ ▸ products (2.3K) ▸ orders (156K) │
│ │
│ Click any table for details, or ask: │
│ "Show me the users schema" │
│ "Which tables have the most records?" │
└─────────────────────────────────────────┘
```
The second approach:
- Shows what matters (counts, top tables)
- Invites conversation ("ask me about...")
- Single-purpose (overview only)
- Leads to follow-up questions
---
## Action-Oriented Widgets
### The 2-CTA Rule
> "Limit primary actions per card: Support up to two actions maximum"
```
✅ GOOD: Two clear actions
┌─────────────────────────────────────────┐
│ 📧 Email from John Doe │
│ "Can we reschedule our meeting?" │
│ │
│ [Reply] [Archive] │
└─────────────────────────────────────────┘
❌ BAD: Too many options
┌─────────────────────────────────────────┐
│ 📧 Email from John Doe │
│ "Can we reschedule our meeting?" │
│ │
│ [Reply] [Forward] [Archive] [Delete] │
│ [Mark Unread] [Add Label] [Move] │
└─────────────────────────────────────────┘
```
### Actions Should Complete or Continue Conversation
```javascript
// Action completes in-widget (callTool)
archiveBtn.addEventListener('click', function() {
window.openai.callTool('archive_email', { id: emailId })
.then(function() {
showToast('Archived');
// Widget updates inline
});
});
// Action continues conversation (sendFollowUpMessage)
replyBtn.addEventListener('click', function() {
window.openai.sendFollowUpMessage({
prompt: 'I want to reply to this email from John about rescheduling'
});
// AI takes over to help draft reply
});
```
---
## Information Density vs Interaction Depth
### Information-Dense (Read-Heavy)
When: User needs to scan/understand data
```
┌─────────────────────────────────────────┐
│ 📊 API Health Dashboard │
├─────────────────────────────────────────┤
│ /api/users ✓ 45ms 99.9% uptime │
│ /api/orders ✓ 120ms 99.7% uptime │
│ /api/payments ⚠ 890ms 98.2% uptime │
│ /api/reports ✓ 230ms 99.5% uptime │
├─────────────────────────────────────────┤
│ 4 endpoints • 1 warning • Avg 321ms │
└─────────────────────────────────────────┘
```
- Lots of data, minimal interaction
- Glanceable, scannable
- Numbers/status at a glance
- Expand via conversation: "Tell me about the payments endpoint"
### Interaction-Deep (Action-Heavy)
When: User needs to make decisions/changes
```
┌─────────────────────────────────────────┐
│ 🔀 Merge Duplicates? │
│ │
│ "Rob B" (12 interactions) │
│ └── Likely same as ──┘ │
│ "Robert Boulos" (45 interactions) │
│ │
│ [Merge into Robert] [Keep Separate] │
└─────────────────────────────────────────┘
```
- Focused on one decision
- Clear consequences
- Two actions maximum
- Outcome matters
---
## When to Use Each Display Mode
### Inline Card (Default)
```
USE FOR:
- Quick confirmations
- Single data points
- Status updates
- Simple actions (1-2 buttons)
EXAMPLES:
- "Order #1234 shipped ✓"
- "Meeting confirmed for 3pm"
- "Table 'users' has 42,000 records"
```
### Inline Carousel
```
USE FOR:
- 3-8 similar items
- Items with images/icons
- Browse and select patterns
EXAMPLES:
- Restaurant recommendations
- Product options
- Recent documents
```
### Fullscreen
```
USE FOR:
- Rich exploration (maps, diagrams)
- Multi-step workflows
- Content that needs space
EXAMPLES:
- Interactive database schema diagram
- Document editor with preview
- Calendar week view
REMEMBER: Composer is always visible!
```
### Picture-in-Picture
```
USE FOR:
- Ongoing sessions
- Reactions to chat input
- Persistent state during conversation
EXAMPLES:
- Live quiz game
- Collaborative editing
- Video playback
```
---
## Practical Examples
### Example 1: Table Explorer (Before/After)
**Before (1:1 Clone):**
```
- Full table list with sorting
- Inline record editing
- Schema modification
- Import/export buttons
- Filter builder
- Pagination with page numbers
```
**After (Conversation-First):**
```
Widget shows:
- Top 10 tables by record count
- Click to expand schema (pre-loaded)
- Visual indicators (auth, record count)
Conversation handles:
- "Edit the users table schema"
- "Export transactions to CSV"
- "Show tables with more than 10K records"
- "Add a new field to orders"
```
### Example 2: Email (Before/After)
**Before (1:1 Clone):**
```
- Full inbox with all emails
- Compose window
- Folder navigation
- Search with filters
- Rich text editor
- Attachment handling
```
**After (Conversation-First):**
```
Widget shows:
- 5 most recent/important emails
- Quick reply/archive actions
- Read/unread indicators
Conversation handles:
- "Draft a reply to John"
- "Find emails from last week about invoices"
- "Archive all newsletters"
- "Forward this to the team"
```
### Example 3: API Endpoints (Before/After)
**Before (1:1 Clone):**
```
- Full endpoint list
- Request builder
- Response viewer
- Headers editor
- Authentication config
- Documentation panel
```
**After (Conversation-First):**
```
Widget shows:
- Endpoint groups overview
- Health status indicators
- Recent failures highlighted
Conversation handles:
- "Test the /api/users endpoint"
- "Show me the request history"
- "Why is payments slow?"
- "Add authentication to this endpoint"
```
---
## Design Decision Framework
### Question 1: Can ChatGPT handle this?
```
If YES → Let ChatGPT do it
- Text generation
- Data analysis
- Decision making
- Complex queries
If NO → Widget should handle it
- Visual display
- Quick actions
- Real-time data
- Interactive selection
```
### Question 2: Is this glanceable?
```
If YES → Inline card
- Status, count, simple list
If NEEDS_SCAN → Inline carousel or compact list
- Multiple similar items
If NEEDS_EXPLORATION → Fullscreen
- Maps, diagrams, rich content
```
### Question 3: What's the ONE thing?
```
Every widget should answer: "What's the one thing the user can DO here?"
- NOT: "Everything they could do in our app"
- YES: "See their tables and click to explore"
- YES: "Approve or reject this request"
- YES: "Understand the schema structure"
```
### Question 4: What happens next?
```
After the widget action:
- Does conversation continue? → sendFollowUpMessage
- Does data update? → callTool
- Is it done? → Toast/confirmation
```
---
## Summary: The Conversation-First Checklist
Before building a widget, ask:
- [ ] **Single purpose**: Does it do ONE thing well?
- [ ] **Conversation-friendly**: Does it enhance, not replace, the chat?
- [ ] **2 CTAs max**: Are there only 1-2 primary actions?
- [ ] **No navigation**: Is it self-contained without tabs/breadcrumbs?
- [ ] **No duplicate features**: Does it avoid replicating the composer?
- [ ] **Glanceable**: Can users understand it in 3 seconds?
- [ ] **Invites follow-up**: Does it lead to natural conversation?
---
## Sources
- [OpenAI Apps SDK - UI Guidelines](https://developers.openai.com/apps-sdk/concepts/ui-guidelines/)
- [OpenAI Apps SDK - Build Your ChatGPT UI](https://developers.openai.com/apps-sdk/build/chatgpt-ui/)
- [OpenAI Apps SDK Examples](https://github.com/openai/openai-apps-sdk-examples)
---
## Next Steps
- See [ui-guidelines.md](ui-guidelines.md) for visual design rules
- See [reactive-widgets.md](reactive-widgets.md) for data hydration
- See [domain-widgets.md](domain-widgets.md) for multi-view patterns
One widget per domain, not per tool.
Instead of creating separate widgets for each tool:
email_list_widget, email_read_widget, email_reply_widget, email_compose_widgetCreate ONE widget that handles the entire domain:
inbox_widget - handles list, read, reply, compose, send, archive, trashcallTool| Domain | Widget | Handles |
|---|---|---|
inbox_widget |
list, read, reply, compose, send, draft, archive, trash | |
| Calendar | calendar_widget |
events, create, respond, availability |
| Videos | videos_widget |
list, details, process stages, upload |
| FreshBooks | freshbooks_widget |
invoices, clients, send, time tracking |
| Search | dashboard_widget |
unified search, entities, evidence, decisions, actions |
| Method | Behavior | Use When |
|---|---|---|
callTool() |
Stays in widget, updates data inline | Fetching details, executing actions, CRUD operations |
sendFollowUpMessage() |
Breaks out to ChatGPT conversation | Handing off to AI for decisions, complex multi-step workflows |
javascript// User clicks email → fetch full content → display inline
emailRow.addEventListener('click', function() {
state.loading = true;
render();
window.openai.callTool('my_execute', {
tool_id: 'email_message',
arguments: { message_id: emailId }
}).then(function(result) {
state.fullEmailData = result.data;
state.view = 'read'; // Morph to read view
state.loading = false;
render();
});
});
The widget updates in place - no new message in chat, no new widget spawned.
javascript// Only when you need ChatGPT to make a decision
reviewBtn.addEventListener('click', function() {
window.openai.sendFollowUpMessage({
prompt: 'Found duplicates: "Rob B" and "Robert Boulos". Should I merge them?'
});
});
This posts a message to the chat - the widget interaction ends, ChatGPT responds.
User clicks something in widget
↓
Need to fetch/execute something?
↓
YES → Can it be done with a single tool call?
↓
YES → Use callTool() → Update widget inline
↓
NO → Does it need AI reasoning?
↓
YES → Use sendFollowUpMessage()
↓
NO → Chain callTool() calls
The widget morphs based on state.view. One widget, many faces.
javascriptvar state = window.openai?.widgetState || {
view: 'list', // Current view: list, read, compose, reply, action, merge
selectedItem: null, // Currently selected item
fullData: null, // Full data fetched via callTool
draftContent: {}, // Form data being composed
loading: false, // Loading state
actionParams: {} // Parameters for action execution
};
javascriptfunction render() {
if (state.loading) {
root.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
return;
}
switch (state.view) {
case 'list':
renderList();
break;
case 'read':
renderDetail();
break;
case 'compose':
renderCompose();
break;
case 'reply':
renderReply();
break;
case 'action':
renderAction();
break;
case 'merge':
renderMerge();
break;
}
}
LIST → click item → READ (fetch via callTool)
READ → click reply → REPLY (prefill from read data)
READ → click archive → LIST (execute via callTool, return to list)
LIST → click compose → COMPOSE (empty form)
COMPOSE → click send → LIST (execute via callTool, return to list)
javascriptfunction saveState() {
if (window.openai?.setWidgetState) {
window.openai.setWidgetState(state);
}
}
// Call after EVERY state change
state.view = 'read';
saveState();
render();
javascript(function() {
'use strict';
var root = document.getElementById('my-mcp-inbox-root');
if (!root) return;
var toolOutput = window.openai?.toolOutput;
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data</div>';
return;
}
var data = toolOutput.structuredContent || toolOutput;
var emails = data.items || [];
// Restore persisted state
var state = window.openai?.widgetState || {
view: 'list',
selectedEmail: null,
fullEmailData: null,
replyMode: false,
composeMode: false,
draftContent: { to: '', subject: '', body: '' },
loading: false
};
render();
function saveState() {
if (window.openai?.setWidgetState) {
window.openai.setWidgetState(state);
}
}
function render() {
if (state.loading) {
root.innerHTML = '<div class="loading"><div class="spinner"></div>Loading...</div>';
return;
}
if (state.composeMode) {
renderCompose();
} else if (state.view === 'read' && state.selectedEmail) {
renderEmail();
} else if (state.replyMode) {
renderReply();
} else {
renderList();
}
}
function renderList() {
var html = '<div class="inbox">';
html += '<div class="inbox-header">';
html += '<div class="inbox-title">Inbox</div>';
html += '<button class="compose-btn" data-action="compose">Compose</button>';
html += '</div>';
html += '<div class="email-list">';
for (var i = 0; i < emails.length; i++) {
var email = emails[i];
html += '<div class="email-row" data-id="' + email.id + '" data-action="read">';
html += '<div class="email-sender">' + esc(email.title) + '</div>';
html += '<div class="email-subject">' + esc(email.subtitle) + '</div>';
html += '</div>';
}
html += '</div></div>';
root.innerHTML = html;
attachListHandlers();
}
function renderEmail() {
var email = state.selectedEmail;
var full = state.fullEmailData;
var html = '<div class="inbox">';
html += '<button class="back-btn" data-action="back">← Back</button>';
html += '<div class="email-view">';
html += '<div class="email-subject">' + esc(full?.subject || email.subtitle) + '</div>';
html += '<div class="email-from">From: ' + esc(full?.from || email.title) + '</div>';
html += '<div class="email-body">' + esc(full?.body || 'Loading...') + '</div>';
html += '<div class="email-actions">';
html += '<button class="action-btn primary" data-action="reply">Reply</button>';
html += '<button class="action-btn" data-action="archive">Archive</button>';
html += '</div></div></div>';
root.innerHTML = html;
attachEmailHandlers();
// Fetch full email if not loaded
if (!full && window.openai?.callTool) {
window.openai.callTool('my_execute', {
tool_id: 'email_message',
arguments: { message_id: email.id }
}).then(function(result) {
state.fullEmailData = result?.data || result;
render();
});
}
}
function attachListHandlers() {
// Compose button
root.querySelector('[data-action="compose"]')?.addEventListener('click', function() {
state.composeMode = true;
state.draftContent = { to: '', subject: '', body: '' };
saveState();
render();
});
// Email rows - click to read
root.querySelectorAll('[data-action="read"]').forEach(function(row) {
row.addEventListener('click', function() {
var id = this.getAttribute('data-id');
state.selectedEmail = emails.find(function(e) { return e.id === id; });
state.fullEmailData = null;
state.view = 'read';
saveState();
render();
});
});
}
function attachEmailHandlers() {
root.querySelector('[data-action="back"]')?.addEventListener('click', function() {
state.view = 'list';
state.selectedEmail = null;
state.fullEmailData = null;
saveState();
render();
});
root.querySelector('[data-action="reply"]')?.addEventListener('click', function() {
state.replyMode = true;
saveState();
render();
});
root.querySelector('[data-action="archive"]')?.addEventListener('click', function() {
state.loading = true;
saveState();
render();
window.openai.callTool('my_execute', {
tool_id: 'email_batch_action',
arguments: { message_ids: [state.selectedEmail.id], action: 'archive' }
}).then(function() {
state.loading = false;
state.view = 'list';
state.selectedEmail = null;
saveState();
showToast('Email archived');
});
});
}
function showToast(msg) {
var toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = msg;
root.appendChild(toast);
setTimeout(function() { toast.remove(); }, 3000);
}
function esc(t) {
if (t == null) return '';
return String(t).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
})();
Browser/CDN caches old widget JS/CSS. Users see stale UI after deploy.
Use a centralized version constant (not inline per-widget):
typescript// src/resources/ui.ts
// =====================================================
// CACHE BUSTER VERSION - Increment on EVERY deploy
// =====================================================
const WIDGET_VERSION = 'v5';
function getWidgetHtml(widgetId: string, rootId: string): string {
const cacheBuster = `?${WIDGET_VERSION}`;
return `<div id="${rootId}"></div>
<link rel="stylesheet" href="${ASSETS_BASE}/widgets/${widgetId}.css${cacheBuster}">
<script src="${ASSETS_BASE}/widgets/${widgetId}.js${cacheBuster}"></script>`;
}
assets.tsWIDGET_VERSION constant: 'v5' → 'v6'npm run build && npm run deploytypescript// =====================================================
// VERSION HISTORY (keep last 5 entries)
// v1: Initial release
// v2: Added reply inline
// v3: Fixed compose flow
// v4: Added compact mode
// v5: Pre-loaded schemas, CSS Grid animation
// =====================================================
const WIDGET_VERSION = 'v5';
See cdn-assets.md for complete cache busting documentation.
When building a new domain widget:
{domain}_widget tool in src/tools/meta/widgets/outputTemplate to ui://my-mcp/{domain}structuredContent with all data needed for list viewgetDynamicWidgetHtml() in src/resources/ui.ts{domain} in widget registrymy-mcp-{domain}-rootWIDGET_{DOMAIN}_JS to src/widgets/assets.tsview, selectedItem, loading, etc.callTool() for all CRUD operationssaveState() after every state changeWIDGET_{DOMAIN}_CSS to src/widgets/assets.tswidgetAssets in src/index.ts?v=1-apple-system, BlinkMacSystemFont, Robotocss/* ❌ WRONG - Creates nested scroll */
.email-body {
max-height: 300px;
overflow-y: auto;
}
/* ✅ CORRECT - Let card grow */
.email-body {
/* No max-height, no overflow - card expands to fit */
}
For long content, truncate in JS instead:
javascriptvar body = fullEmail.body || '';
if (body.length > 500) {
body = body.substring(0, 500) + '... [truncated]';
}
javascript// ❌ WRONG - 3 buttons violates max 2 CTAs rule
html += '<button data-action="cancel">Cancel</button>';
html += '<button data-action="save">Save Draft</button>';
html += '<button data-action="send">Send</button>';
// ✅ CORRECT - Use back button for cancel, 2 action buttons
html += '<button class="back-btn" data-action="cancel">← Cancel</button>';
// ... compose form ...
html += '<div class="compose-actions">';
html += '<button data-action="save">💾 Save Draft</button>';
html += '<button class="primary" data-action="send">📤 Send</button>';
html += '</div>';
The most common bug when widgets call backend tools via callTool:
javascript// ❌ WRONG - Widget passing wrong parameter name
window.openai.callTool('my_execute', {
tool_id: 'kg_entity_merge',
arguments: {
target_id: state.mergeTarget.id,
source_id: state.mergeSource.id // ← Tool expects merge_ids (array)!
}
});
// ✅ CORRECT - Match the tool's schema exactly
window.openai.callTool('my_execute', {
tool_id: 'kg_entity_merge',
arguments: {
target_id: state.mergeTarget.id,
merge_ids: [state.mergeSource.id] // ← Correct: array of IDs
}
});
Debug Process:
src/tools/{category}/{tool}/index.tsjavascript// ❌ WRONG - Forgot to re-render
window.openai.callTool('tool_id', args).then(function(result) {
state.loading = false;
saveState();
// Missing render()!
});
// ✅ CORRECT - Always render after state change
window.openai.callTool('tool_id', args).then(function(result) {
state.loading = false;
saveState();
render(); // ← Must re-render to show changes
});
javascript// ❌ WRONG - Forgot to save state
state.view = 'list';
render(); // State lost if widget reloads
// ✅ CORRECT - Always saveState before render
state.view = 'list';
saveState(); // ← Persist first
render();
# Domain Widgets - One Widget Per Domain Pattern
## Table of Contents
- [The Core Principle](#the-core-principle)
- [callTool vs sendFollowUpMessage](#calltool-vs-sendfollowupmessage)
- [State-Driven View Morphing](#state-driven-view-morphing)
- [Complete Inbox Widget Example](#complete-inbox-widget-example)
- [Cache Busting](#cache-busting)
- [Domain Widget Checklist](#domain-widget-checklist)
---
## The Core Principle
**One widget per domain, not per tool.**
Instead of creating separate widgets for each tool:
- ❌ `email_list_widget`, `email_read_widget`, `email_reply_widget`, `email_compose_widget`
Create ONE widget that handles the entire domain:
- ✅ `inbox_widget` - handles list, read, reply, compose, send, archive, trash
### Why This Matters
1. **Seamless UX** - User stays in the same frame, no jarring context switches
2. **State Persistence** - Widget remembers where user was (view, selected item, draft content)
3. **Fewer Tool Calls** - ChatGPT doesn't need to invoke new tools for each action
4. **Rich Interactions** - Full CRUD operations inline via `callTool`
### Domain Mapping
| Domain | Widget | Handles |
|--------|--------|---------|
| Email | `inbox_widget` | list, read, reply, compose, send, draft, archive, trash |
| Calendar | `calendar_widget` | events, create, respond, availability |
| Videos | `videos_widget` | list, details, process stages, upload |
| FreshBooks | `freshbooks_widget` | invoices, clients, send, time tracking |
| Search | `dashboard_widget` | unified search, entities, evidence, decisions, actions |
---
## callTool vs sendFollowUpMessage
### CRITICAL: Know When to Use Each
| Method | Behavior | Use When |
|--------|----------|----------|
| `callTool()` | **Stays in widget**, updates data inline | Fetching details, executing actions, CRUD operations |
| `sendFollowUpMessage()` | **Breaks out** to ChatGPT conversation | Handing off to AI for decisions, complex multi-step workflows |
### callTool - Stays in Widget (PREFERRED)
```javascript
// User clicks email → fetch full content → display inline
emailRow.addEventListener('click', function() {
state.loading = true;
render();
window.openai.callTool('my_execute', {
tool_id: 'email_message',
arguments: { message_id: emailId }
}).then(function(result) {
state.fullEmailData = result.data;
state.view = 'read'; // Morph to read view
state.loading = false;
render();
});
});
```
The widget updates **in place** - no new message in chat, no new widget spawned.
### sendFollowUpMessage - Breaks Out (USE SPARINGLY)
```javascript
// Only when you need ChatGPT to make a decision
reviewBtn.addEventListener('click', function() {
window.openai.sendFollowUpMessage({
prompt: 'Found duplicates: "Rob B" and "Robert Boulos". Should I merge them?'
});
});
```
This posts a message to the chat - the widget interaction ends, ChatGPT responds.
### Decision Tree
```
User clicks something in widget
↓
Need to fetch/execute something?
↓
YES → Can it be done with a single tool call?
↓
YES → Use callTool() → Update widget inline
↓
NO → Does it need AI reasoning?
↓
YES → Use sendFollowUpMessage()
↓
NO → Chain callTool() calls
```
---
## State-Driven View Morphing
The widget morphs based on `state.view`. One widget, many faces.
### State Structure
```javascript
var state = window.openai?.widgetState || {
view: 'list', // Current view: list, read, compose, reply, action, merge
selectedItem: null, // Currently selected item
fullData: null, // Full data fetched via callTool
draftContent: {}, // Form data being composed
loading: false, // Loading state
actionParams: {} // Parameters for action execution
};
```
### View Morphing Pattern
```javascript
function render() {
if (state.loading) {
root.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
return;
}
switch (state.view) {
case 'list':
renderList();
break;
case 'read':
renderDetail();
break;
case 'compose':
renderCompose();
break;
case 'reply':
renderReply();
break;
case 'action':
renderAction();
break;
case 'merge':
renderMerge();
break;
}
}
```
### View Transitions
```
LIST → click item → READ (fetch via callTool)
READ → click reply → REPLY (prefill from read data)
READ → click archive → LIST (execute via callTool, return to list)
LIST → click compose → COMPOSE (empty form)
COMPOSE → click send → LIST (execute via callTool, return to list)
```
### Always Save State
```javascript
function saveState() {
if (window.openai?.setWidgetState) {
window.openai.setWidgetState(state);
}
}
// Call after EVERY state change
state.view = 'read';
saveState();
render();
```
---
## Complete Inbox Widget Example
### The Pattern in Action
```javascript
(function() {
'use strict';
var root = document.getElementById('my-mcp-inbox-root');
if (!root) return;
var toolOutput = window.openai?.toolOutput;
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data</div>';
return;
}
var data = toolOutput.structuredContent || toolOutput;
var emails = data.items || [];
// Restore persisted state
var state = window.openai?.widgetState || {
view: 'list',
selectedEmail: null,
fullEmailData: null,
replyMode: false,
composeMode: false,
draftContent: { to: '', subject: '', body: '' },
loading: false
};
render();
function saveState() {
if (window.openai?.setWidgetState) {
window.openai.setWidgetState(state);
}
}
function render() {
if (state.loading) {
root.innerHTML = '<div class="loading"><div class="spinner"></div>Loading...</div>';
return;
}
if (state.composeMode) {
renderCompose();
} else if (state.view === 'read' && state.selectedEmail) {
renderEmail();
} else if (state.replyMode) {
renderReply();
} else {
renderList();
}
}
function renderList() {
var html = '<div class="inbox">';
html += '<div class="inbox-header">';
html += '<div class="inbox-title">Inbox</div>';
html += '<button class="compose-btn" data-action="compose">Compose</button>';
html += '</div>';
html += '<div class="email-list">';
for (var i = 0; i < emails.length; i++) {
var email = emails[i];
html += '<div class="email-row" data-id="' + email.id + '" data-action="read">';
html += '<div class="email-sender">' + esc(email.title) + '</div>';
html += '<div class="email-subject">' + esc(email.subtitle) + '</div>';
html += '</div>';
}
html += '</div></div>';
root.innerHTML = html;
attachListHandlers();
}
function renderEmail() {
var email = state.selectedEmail;
var full = state.fullEmailData;
var html = '<div class="inbox">';
html += '<button class="back-btn" data-action="back">← Back</button>';
html += '<div class="email-view">';
html += '<div class="email-subject">' + esc(full?.subject || email.subtitle) + '</div>';
html += '<div class="email-from">From: ' + esc(full?.from || email.title) + '</div>';
html += '<div class="email-body">' + esc(full?.body || 'Loading...') + '</div>';
html += '<div class="email-actions">';
html += '<button class="action-btn primary" data-action="reply">Reply</button>';
html += '<button class="action-btn" data-action="archive">Archive</button>';
html += '</div></div></div>';
root.innerHTML = html;
attachEmailHandlers();
// Fetch full email if not loaded
if (!full && window.openai?.callTool) {
window.openai.callTool('my_execute', {
tool_id: 'email_message',
arguments: { message_id: email.id }
}).then(function(result) {
state.fullEmailData = result?.data || result;
render();
});
}
}
function attachListHandlers() {
// Compose button
root.querySelector('[data-action="compose"]')?.addEventListener('click', function() {
state.composeMode = true;
state.draftContent = { to: '', subject: '', body: '' };
saveState();
render();
});
// Email rows - click to read
root.querySelectorAll('[data-action="read"]').forEach(function(row) {
row.addEventListener('click', function() {
var id = this.getAttribute('data-id');
state.selectedEmail = emails.find(function(e) { return e.id === id; });
state.fullEmailData = null;
state.view = 'read';
saveState();
render();
});
});
}
function attachEmailHandlers() {
root.querySelector('[data-action="back"]')?.addEventListener('click', function() {
state.view = 'list';
state.selectedEmail = null;
state.fullEmailData = null;
saveState();
render();
});
root.querySelector('[data-action="reply"]')?.addEventListener('click', function() {
state.replyMode = true;
saveState();
render();
});
root.querySelector('[data-action="archive"]')?.addEventListener('click', function() {
state.loading = true;
saveState();
render();
window.openai.callTool('my_execute', {
tool_id: 'email_batch_action',
arguments: { message_ids: [state.selectedEmail.id], action: 'archive' }
}).then(function() {
state.loading = false;
state.view = 'list';
state.selectedEmail = null;
saveState();
showToast('Email archived');
});
});
}
function showToast(msg) {
var toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = msg;
root.appendChild(toast);
setTimeout(function() { toast.remove(); }, 3000);
}
function esc(t) {
if (t == null) return '';
return String(t).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
})();
```
---
## Cache Busting
### The Problem
Browser/CDN caches old widget JS/CSS. Users see stale UI after deploy.
### The Solution
Use a **centralized version constant** (not inline per-widget):
```typescript
// src/resources/ui.ts
// =====================================================
// CACHE BUSTER VERSION - Increment on EVERY deploy
// =====================================================
const WIDGET_VERSION = 'v5';
function getWidgetHtml(widgetId: string, rootId: string): string {
const cacheBuster = `?${WIDGET_VERSION}`;
return `<div id="${rootId}"></div>
<link rel="stylesheet" href="${ASSETS_BASE}/widgets/${widgetId}.css${cacheBuster}">
<script src="${ASSETS_BASE}/widgets/${widgetId}.js${cacheBuster}"></script>`;
}
```
### Version Bump Workflow
1. Make changes to widget JS/CSS in `assets.ts`
2. Bump `WIDGET_VERSION` constant: `'v5'` → `'v6'`
3. `npm run build && npm run deploy`
4. Users get fresh assets
### Version History Comment
```typescript
// =====================================================
// VERSION HISTORY (keep last 5 entries)
// v1: Initial release
// v2: Added reply inline
// v3: Fixed compose flow
// v4: Added compact mode
// v5: Pre-loaded schemas, CSS Grid animation
// =====================================================
const WIDGET_VERSION = 'v5';
```
**See [cdn-assets.md](cdn-assets.md#cache-busting-the-right-way) for complete cache busting documentation.**
---
## Domain Widget Checklist
When building a new domain widget:
### 1. Tool Setup
- [ ] Create `{domain}_widget` tool in `src/tools/meta/widgets/`
- [ ] Point `outputTemplate` to `ui://my-mcp/{domain}`
- [ ] Return rich `structuredContent` with all data needed for list view
### 2. Resource Setup
- [ ] Add case to `getDynamicWidgetHtml()` in `src/resources/ui.ts`
- [ ] Register `{domain}` in widget registry
- [ ] Use unique root element ID: `my-mcp-{domain}-root`
### 3. Widget JS
- [ ] Add `WIDGET_{DOMAIN}_JS` to `src/widgets/assets.ts`
- [ ] Implement state with `view`, `selectedItem`, `loading`, etc.
- [ ] Use `callTool()` for all CRUD operations
- [ ] Use `saveState()` after every state change
- [ ] Add toast notifications for feedback
### 4. Widget CSS
- [ ] Add `WIDGET_{DOMAIN}_CSS` to `src/widgets/assets.ts`
- [ ] Include loading spinner, toast styles
- [ ] Style all views: list, detail, compose, etc.
### 5. Asset Serving
- [ ] Add to `widgetAssets` in `src/index.ts`
- [ ] Include cache bust version: `?v=1`
### 6. Testing
- [ ] List view renders with data
- [ ] Click item → detail view loads via callTool
- [ ] Actions execute via callTool, return to list
- [ ] State persists across re-renders
- [ ] Toast notifications appear
---
## UI Guidelines Compliance
### The Rules (from OpenAI Apps SDK)
1. **Max 2 CTAs per card** - One primary, one secondary max
2. **No nested scrolling** - Cards should auto-fit content
3. **System fonts only** - `-apple-system, BlinkMacSystemFont, Roboto`
4. **System colors** - Brand accents on buttons only
5. **No deep navigation** - Single-purpose cards
6. **Work WITH the composer** - Don't replicate input fields
### Fixing Nested Scrolling
```css
/* ❌ WRONG - Creates nested scroll */
.email-body {
max-height: 300px;
overflow-y: auto;
}
/* ✅ CORRECT - Let card grow */
.email-body {
/* No max-height, no overflow - card expands to fit */
}
```
For long content, truncate in JS instead:
```javascript
var body = fullEmail.body || '';
if (body.length > 500) {
body = body.substring(0, 500) + '... [truncated]';
}
```
### Fixing Too Many CTAs
```javascript
// ❌ WRONG - 3 buttons violates max 2 CTAs rule
html += '<button data-action="cancel">Cancel</button>';
html += '<button data-action="save">Save Draft</button>';
html += '<button data-action="send">Send</button>';
// ✅ CORRECT - Use back button for cancel, 2 action buttons
html += '<button class="back-btn" data-action="cancel">← Cancel</button>';
// ... compose form ...
html += '<div class="compose-actions">';
html += '<button data-action="save">💾 Save Draft</button>';
html += '<button class="primary" data-action="send">📤 Send</button>';
html += '</div>';
```
---
## Common Bugs & Debugging
### API Parameter Mismatch (Critical!)
The most common bug when widgets call backend tools via `callTool`:
```javascript
// ❌ WRONG - Widget passing wrong parameter name
window.openai.callTool('my_execute', {
tool_id: 'kg_entity_merge',
arguments: {
target_id: state.mergeTarget.id,
source_id: state.mergeSource.id // ← Tool expects merge_ids (array)!
}
});
// ✅ CORRECT - Match the tool's schema exactly
window.openai.callTool('my_execute', {
tool_id: 'kg_entity_merge',
arguments: {
target_id: state.mergeTarget.id,
merge_ids: [state.mergeSource.id] // ← Correct: array of IDs
}
});
```
**Debug Process:**
1. Check the tool's Zod schema in `src/tools/{category}/{tool}/index.ts`
2. Match parameter names and types exactly
3. Arrays vs single values - tools often expect arrays for batch operations
### Widget Not Updating After callTool
```javascript
// ❌ WRONG - Forgot to re-render
window.openai.callTool('tool_id', args).then(function(result) {
state.loading = false;
saveState();
// Missing render()!
});
// ✅ CORRECT - Always render after state change
window.openai.callTool('tool_id', args).then(function(result) {
state.loading = false;
saveState();
render(); // ← Must re-render to show changes
});
```
### State Not Persisting Across Tool Calls
```javascript
// ❌ WRONG - Forgot to save state
state.view = 'list';
render(); // State lost if widget reloads
// ✅ CORRECT - Always saveState before render
state.view = 'list';
saveState(); // ← Persist first
render();
```
---
## Sources
- Production implementation: my-mcp inbox_widget, dashboard_widget
- [OpenAI Apps SDK - State Management](https://developers.openai.com/apps-sdk/build/state-management/)
---
## Next Steps
- See [interactive-widgets.md](interactive-widgets.md) for window.openai API reference
- See [widgets.md](widgets.md) for basic widget patterns
- See [examples.md](examples.md) for more domain widget examples
All examples are from the production my-mcp implementation.
src/tools/meta/widgets/inbox.ts#typescript/**
* Widget Tool: Inbox Widget
*
* Fetches emails and returns data for the list widget.
* Tool returns data in structuredContent + points to resource via _meta["openai/outputTemplate"].
* The resource serves the HTML template that reads data from window.openai.toolOutput.
*/
import { z } from 'zod';
import { xano } from '../../../services/xano/adapter';
import { formatError } from '../../../response/formatter';
// Schema
export const schema = z.object({
limit: z.number().min(1).max(50).default(20).describe('Max messages to show'),
query: z.string().optional().describe('Gmail search query (e.g., "is:unread")'),
account: z.enum(['work', 'personal']).default('work').describe('Email account'),
});
// Widget Metadata for OpenAI Apps SDK
export function getWidgetMeta() {
return {
'openai/outputTemplate': 'ui://my-mcp/list',
'openai/toolInvocation/invoking': 'Loading inbox...',
'openai/toolInvocation/invoked': 'Inbox ready',
'openai/widgetAccessible': true,
} as const;
}
// Handler
interface EmailMessage {
id: string;
thread_id?: string;
from: string;
subject: string;
date?: string;
snippet?: string;
is_processed?: boolean;
labels?: string[];
}
export async function handler(params: z.infer<typeof schema>) {
try {
// Fetch real email data
const result = await xano.post<{
success: boolean;
total: number;
new_count: number;
messages: EmailMessage[];
}>('email/smart-inbox', {
max_results: params.limit,
mode: 'compact',
query: params.query || 'in:inbox',
account: params.account,
});
if (!result.success) {
return formatError(result.error || 'Failed to fetch inbox', result.statusCode || 500);
}
const messages = result.data?.messages || [];
const total = result.data?.total || 0;
const newCount = result.data?.new_count || 0;
// Transform to widget format
const items = messages.map((msg) => {
const senderMatch = msg.from?.match(/^([^<]+)/);
const senderName = senderMatch ? senderMatch[1].trim().replace(/"/g, '') : msg.from || 'Unknown';
const subject = msg.subject || '(No subject)';
const snippet = msg.snippet?.substring(0, 80).replace(/\s+/g, ' ').trim() || '';
return {
id: msg.id,
title: senderName,
subtitle: subject,
description: snippet,
badge: msg.is_processed === false ? 'new' : undefined,
metadata: {
thread_id: msg.thread_id,
date: msg.date,
labels: msg.labels,
},
};
});
// Build structuredContent for widget
const structuredContent = {
title: `Inbox (${total} messages${newCount > 0 ? `, ${newCount} new` : ''})`,
items,
summary: {
total,
new_count: newCount,
returned: messages.length,
},
};
// Return in OpenAI Apps SDK format
return {
content: [
{
type: 'text',
text: `Showing ${messages.length} of ${total} emails${newCount > 0 ? ` (${newCount} new)` : ''}`,
},
],
structuredContent,
_meta: getWidgetMeta(),
};
} catch (error) {
return formatError(
`Failed to load inbox: ${error instanceof Error ? error.message : String(error)}`,
500
);
}
}
// Metadata
export const metadata = {
id: 'inbox_widget',
name: 'Inbox Widget',
description: 'Display email inbox as an interactive visual widget.',
category: 'ui',
operation: 'read' as const,
};
src/tools/meta/widgets/calendar.ts#typescript/**
* Widget Tool: Calendar Widget
*
* Fetches calendar events from both work and personal calendars.
* Merges, sorts by time, and returns as widget data.
*/
import { z } from 'zod';
import { xano } from '../../../services/xano/adapter';
import { formatError } from '../../../response/formatter';
export const schema = z.object({
time_min: z.string().optional().describe('Start time in ISO format. Defaults to today'),
time_max: z.string().optional().describe('End time in ISO format. Defaults to 7 days from now'),
max_results: z.number().min(1).max(50).default(20).describe('Maximum events to return'),
});
export function getWidgetMeta() {
return {
'openai/outputTemplate': 'ui://my-mcp/calendar',
'openai/toolInvocation/invoking': 'Loading calendar...',
'openai/toolInvocation/invoked': 'Calendar ready',
'openai/widgetAccessible': true,
} as const;
}
interface CalendarEvent {
id: string;
summary: string;
start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string };
location?: string;
_account?: 'work' | 'personal';
}
export async function handler(params: z.infer<typeof schema>) {
try {
// Fetch both calendars in parallel
const [workResult, personalResult] = await Promise.all([
xano.get<{ events?: CalendarEvent[] }>('calendar/events', {
...(params.time_min && { time_min: params.time_min }),
...(params.time_max && { time_max: params.time_max }),
max_results: 50,
account: 'work',
}),
xano.get<{ events?: CalendarEvent[] }>('calendar/events', {
...(params.time_min && { time_min: params.time_min }),
...(params.time_max && { time_max: params.time_max }),
max_results: 50,
account: 'personal',
}),
]);
// Tag events with their source
const workEvents = (workResult.data?.events || []).map(e => ({ ...e, _account: 'work' as const }));
const personalEvents = (personalResult.data?.events || []).map(e => ({ ...e, _account: 'personal' as const }));
// Merge and sort by start time
const allEvents = [...workEvents, ...personalEvents].sort((a, b) => {
const aTime = new Date(a.start.dateTime || a.start.date || '').getTime();
const bTime = new Date(b.start.dateTime || b.start.date || '').getTime();
return aTime - bTime;
});
const events = allEvents.slice(0, params.max_results);
// Transform to widget format
const widgetEvents = events.map((event) => ({
id: event.id,
title: event.summary || 'Untitled Event',
start: event.start.dateTime || event.start.date || '',
end: event.end.dateTime || event.end.date || '',
location: event.location,
isAllDay: !event.start.dateTime,
badge: event._account === 'personal' ? 'personal' : 'work',
}));
const structuredContent = {
title: 'Your Calendar',
events: widgetEvents,
summary: {
total: events.length,
work_count: workEvents.length,
personal_count: personalEvents.length,
},
};
return {
content: [{ type: 'text', text: `Showing ${events.length} events` }],
structuredContent,
_meta: getWidgetMeta(),
};
} catch (error) {
return formatError(`Failed to load calendar: ${error instanceof Error ? error.message : String(error)}`, 500);
}
}
export const metadata = {
id: 'calendar_widget',
name: 'Calendar Widget',
description: 'Display calendar events as an interactive visual widget.',
category: 'ui',
operation: 'read' as const,
};
typescriptimport { z } from 'zod';
import { formatError } from '../../../response/formatter';
export const schema = z.object({
category: z.enum(['freshbooks', 'actions', 'email', 'all']).default('all'),
});
export function getWidgetMeta() {
return {
'openai/outputTemplate': 'ui://my-mcp/stats',
'openai/toolInvocation/invoking': 'Loading stats...',
'openai/toolInvocation/invoked': 'Stats ready',
'openai/widgetAccessible': true,
} as const;
}
export async function handler(params: z.infer<typeof schema>) {
try {
// Fetch metrics from various sources
const metrics = {
freshbooks: { outstanding: 12500, this_month: 8200 },
actions: { pending: 5, in_progress: 2, completed_today: 8 },
email: { unread: 12, needs_response: 3 },
};
const structuredContent = {
title: 'Dashboard Stats',
metrics,
category: params.category,
};
return {
content: [{ type: 'text', text: 'Stats loaded' }],
structuredContent,
_meta: getWidgetMeta(),
};
} catch (error) {
return formatError(`Failed to load stats: ${error instanceof Error ? error.message : String(error)}`, 500);
}
}
export const metadata = {
id: 'stats_widget',
name: 'Stats Widget',
description: 'Display dashboard metrics as an interactive widget.',
category: 'ui',
operation: 'read' as const,
};
typescriptimport { z } from 'zod';
import { formatError } from '../../../response/formatter';
export const schema = z.object({
status: z.enum(['pending', 'processing', 'sent', 'failed', 'cancelled']).default('pending'),
limit: z.number().min(1).max(50).default(10),
});
export function getWidgetMeta() {
return {
'openai/outputTemplate': 'ui://my-mcp/queue',
'openai/toolInvocation/invoking': 'Loading queue...',
'openai/toolInvocation/invoked': 'Queue ready',
'openai/widgetAccessible': true,
} as const;
}
export async function handler(params: z.infer<typeof schema>) {
try {
// Fetch outbound queue items
const queueItems = await fetchQueueItems(params.status, params.limit);
const items = queueItems.map((item) => ({
id: item.id,
title: item.recipient,
subtitle: item.action_type,
description: item.preview,
badge: item.status,
metadata: {
scheduled_for: item.scheduled_for,
can_cancel: item.status === 'pending',
},
}));
const structuredContent = {
title: `Outbound Queue (${params.status})`,
items,
summary: { total: items.length },
};
return {
content: [{ type: 'text', text: `${items.length} items in queue` }],
structuredContent,
_meta: getWidgetMeta(),
};
} catch (error) {
return formatError(`Failed to load queue: ${error instanceof Error ? error.message : String(error)}`, 500);
}
}
export const metadata = {
id: 'queue_widget',
name: 'Queue Widget',
description: 'Display outbound queue as an interactive widget.',
category: 'ui',
operation: 'read' as const,
};
typescript// src/index.ts
import { McpAgent } from 'agents/mcp';
import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { oauthHandler } from './oauth-handler';
import { setupUIResources } from './resources/ui';
import { WIDGET_LIST_JS, WIDGET_LIST_CSS } from './widgets/assets';
// Import widget tools
import * as inboxWidget from './tools/meta/widgets/inbox';
import * as calendarWidget from './tools/meta/widgets/calendar';
import * as statsWidget from './tools/meta/widgets/stats';
import * as queueWidget from './tools/meta/widgets/queue';
import * as actionsWidget from './tools/meta/widgets/actions';
// Widget assets map
const widgetAssets: Record<string, { content: string; type: string }> = {
'/widgets/my-mcp-list.js': { content: WIDGET_LIST_JS, type: 'application/javascript' },
'/widgets/my-mcp-list.css': { content: WIDGET_LIST_CSS, type: 'text/css' },
};
// Auth props interface
interface your serverAuthProps extends Record<string, unknown> {
authenticated?: boolean;
userId?: string;
}
interface your serverState {
initialized?: boolean;
}
// Main MCP Agent
class your serverMCP extends McpAgent<any, your serverState, your serverAuthProps> {
server = new McpServer({
name: 'my-mcp',
version: '1.0.0',
});
initialState: your serverState = {};
async init() {
await this.setupWidgetTools();
setupUIResources(this.server);
console.log('[your serverMCP] Initialized with widget tools');
}
private async setupWidgetTools() {
// Register inbox widget
this.server.registerTool(
inboxWidget.metadata.id,
{
title: inboxWidget.metadata.name,
description: inboxWidget.metadata.description,
inputSchema: inboxWidget.schema,
_meta: inboxWidget.getWidgetMeta(),
},
async (params) => {
const validated = inboxWidget.schema.parse(params);
return await inboxWidget.handler(validated);
}
);
// Register calendar widget
this.server.registerTool(
calendarWidget.metadata.id,
{
title: calendarWidget.metadata.name,
description: calendarWidget.metadata.description,
inputSchema: calendarWidget.schema,
_meta: calendarWidget.getWidgetMeta(),
},
async (params) => {
const validated = calendarWidget.schema.parse(params);
return await calendarWidget.handler(validated);
}
);
// ... register other widget tools similarly
}
}
// OAuth provider
const oauthProvider = new OAuthProvider({
kvNamespace: 'OAUTH_KV',
apiRoute: '/sse',
apiHandler: your serverMCP.mount('/sse'),
defaultHandler: oauthHandler,
authorizeEndpoint: '/authorize',
tokenEndpoint: '/token',
clientRegistrationEndpoint: '/register',
forceHTTPS: false,
lookupClient: async (clientId) => ({
id: clientId || 'default-client',
secret: 'client-secret',
allowed_redirects: ['*'],
name: 'your MCP Client',
}),
});
// Main worker
const mainWorker = {
async fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// Health check
if (url.pathname === '/' || url.pathname === '/health') {
return new Response('your MCP OK', { status: 200 });
}
// Static widget assets with CORS
if (url.pathname.startsWith('/widgets/')) {
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
const asset = widgetAssets[url.pathname];
if (asset) {
return new Response(asset.content, {
headers: {
'Content-Type': asset.type,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=31536000',
},
});
}
return new Response('Not found', { status: 404 });
}
// Bypass OAuth for /sse
if (url.pathname === '/sse' || url.pathname.startsWith('/sse/')) {
return your serverMCP.mount('/sse').fetch(request, env, ctx);
}
// All other routes through OAuth
return oauthProvider.fetch(request, env, ctx);
},
};
export default mainWorker;
export { your serverMCP };
src/resources/ui.ts#typescriptimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const SKYBRIDGE_MIME = 'text/html+skybridge';
const WIDGET_ASSETS_BASE = 'https://my-mcp.robertjboulos.workers.dev';
export function setupUIResources(server: McpServer): void {
// List widget resource
server.resource(
'ui-list',
'ui://my-mcp/list',
{
title: 'List Widget',
description: 'Interactive list widget for emails, items, etc.',
mimeType: SKYBRIDGE_MIME,
},
async (resourceUri) => {
const html = `<div id="widget-list-root"></div>
<link rel="stylesheet" href="${WIDGET_ASSETS_BASE}/widgets/my-mcp-list.css">
<script src="${WIDGET_ASSETS_BASE}/widgets/my-mcp-list.js"></script>`;
return {
contents: [
{
uri: resourceUri.href,
mimeType: SKYBRIDGE_MIME,
text: html,
_meta: {
'openai/widgetPrefersBorder': true,
'openai/widgetDomain': 'https://chatgpt.com',
},
},
],
};
}
);
// Calendar widget resource
server.resource(
'ui-calendar',
'ui://my-mcp/calendar',
{
title: 'Calendar Widget',
description: 'Interactive calendar widget',
mimeType: SKYBRIDGE_MIME,
},
async (resourceUri) => {
const html = `<div id="my-mcp-calendar-root"></div>
<link rel="stylesheet" href="${WIDGET_ASSETS_BASE}/widgets/my-mcp-calendar.css">
<script src="${WIDGET_ASSETS_BASE}/widgets/my-mcp-calendar.js"></script>`;
return {
contents: [
{
uri: resourceUri.href,
mimeType: SKYBRIDGE_MIME,
text: html,
_meta: {
'openai/widgetPrefersBorder': true,
'openai/widgetDomain': 'https://chatgpt.com',
},
},
],
};
}
);
// Add more widget resources as needed...
console.log('[your serverMCP] Registered UI resources');
}
src/widgets/assets.ts#typescriptexport const WIDGET_LIST_JS = `
(function() {
'use strict';
var root = document.getElementById('widget-list-root');
if (!root) {
console.error('[your server List] Root element not found');
return;
}
var toolOutput = window.openai ? window.openai.toolOutput : null;
console.log('[your server List] toolOutput:', toolOutput);
if (!toolOutput) {
root.innerHTML = '<div class="my-mcp-empty">No data available</div>';
return;
}
var data = toolOutput.structuredContent || toolOutput;
var title = data.title || 'Items';
var items = data.items || [];
var summary = data.summary || {};
var subtitle = summary.total
? 'Showing ' + items.length + ' of ' + summary.total
: items.length + ' items';
var html = '<div class="my-mcp-header">';
html += '<div class="my-mcp-title">' + escapeHtml(title) + '</div>';
html += '<div class="my-mcp-subtitle">' + escapeHtml(subtitle) + '</div>';
html += '</div>';
if (items.length === 0) {
html += '<div class="my-mcp-empty">No items to display</div>';
} else {
html += '<div class="my-mcp-items">';
for (var i = 0; i < items.length; i++) {
var item = items[i];
html += '<div class="my-mcp-item" data-id="' + escapeHtml(item.id || '') + '">';
html += '<div class="my-mcp-item-icon">' + (item.icon || getDefaultIcon(item)) + '</div>';
html += '<div class="my-mcp-item-content">';
html += '<div class="my-mcp-item-title">' + escapeHtml(item.title || '') + '</div>';
if (item.subtitle) {
html += '<div class="my-mcp-item-subtitle">' + escapeHtml(item.subtitle) + '</div>';
}
if (item.description) {
html += '<div class="my-mcp-item-desc">' + escapeHtml(item.description) + '</div>';
}
html += '</div>';
if (item.badge) {
html += '<div class="my-mcp-badge">' + escapeHtml(item.badge) + '</div>';
}
html += '</div>';
}
html += '</div>';
}
root.innerHTML = html;
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function getDefaultIcon(item) {
return '📧';
}
})();
`.trim();
export const WIDGET_LIST_CSS = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #ffffff;
padding: 16px;
}
.my-mcp-header { margin-bottom: 16px; }
.my-mcp-title { font-size: 18px; font-weight: 600; color: #1f2937; }
.my-mcp-subtitle { font-size: 13px; color: #6b7280; margin-top: 2px; }
.my-mcp-items { max-height: 400px; overflow-y: auto; }
.my-mcp-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #e5e7eb;
cursor: pointer;
transition: background 150ms ease;
}
.my-mcp-item:hover { background: #f9fafb; }
.my-mcp-item:last-child { border-bottom: none; }
.my-mcp-item-icon {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 50%;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.my-mcp-item-content { flex: 1; min-width: 0; }
.my-mcp-item-title {
font-weight: 500;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.my-mcp-item-subtitle {
font-size: 13px;
color: #4b5563;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.my-mcp-item-desc {
font-size: 12px;
color: #9ca3af;
margin-top: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.my-mcp-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
background: #3b82f6;
color: white;
text-transform: uppercase;
}
.my-mcp-empty { padding: 32px; text-align: center; color: #9ca3af; }
`.trim();
structuredContent with items/events/metrics_meta["openai/outputTemplate"] = resource URItext/html+skybridge/widgets/* endpointswindow.openai.toolOutput contains structuredContent# Complete Code Examples
## Table of Contents
- [Inbox Widget (List Pattern)](#inbox-widget-list-pattern)
- [Calendar Widget (Calendar Pattern)](#calendar-widget-calendar-pattern)
- [Stats Widget (Metrics Pattern)](#stats-widget-metrics-pattern)
- [Queue Widget (Actions Pattern)](#queue-widget-actions-pattern)
- [Index.ts (Main Worker)](#indexts-main-worker)
- [UI Resources Registration](#ui-resources-registration)
- [Widget Assets](#widget-assets)
---
All examples are from the production my-mcp implementation.
---
## Inbox Widget (List Pattern)
### Tool: `src/tools/meta/widgets/inbox.ts`
```typescript
/**
* Widget Tool: Inbox Widget
*
* Fetches emails and returns data for the list widget.
* Tool returns data in structuredContent + points to resource via _meta["openai/outputTemplate"].
* The resource serves the HTML template that reads data from window.openai.toolOutput.
*/
import { z } from 'zod';
import { xano } from '../../../services/xano/adapter';
import { formatError } from '../../../response/formatter';
// Schema
export const schema = z.object({
limit: z.number().min(1).max(50).default(20).describe('Max messages to show'),
query: z.string().optional().describe('Gmail search query (e.g., "is:unread")'),
account: z.enum(['work', 'personal']).default('work').describe('Email account'),
});
// Widget Metadata for OpenAI Apps SDK
export function getWidgetMeta() {
return {
'openai/outputTemplate': 'ui://my-mcp/list',
'openai/toolInvocation/invoking': 'Loading inbox...',
'openai/toolInvocation/invoked': 'Inbox ready',
'openai/widgetAccessible': true,
} as const;
}
// Handler
interface EmailMessage {
id: string;
thread_id?: string;
from: string;
subject: string;
date?: string;
snippet?: string;
is_processed?: boolean;
labels?: string[];
}
export async function handler(params: z.infer<typeof schema>) {
try {
// Fetch real email data
const result = await xano.post<{
success: boolean;
total: number;
new_count: number;
messages: EmailMessage[];
}>('email/smart-inbox', {
max_results: params.limit,
mode: 'compact',
query: params.query || 'in:inbox',
account: params.account,
});
if (!result.success) {
return formatError(result.error || 'Failed to fetch inbox', result.statusCode || 500);
}
const messages = result.data?.messages || [];
const total = result.data?.total || 0;
const newCount = result.data?.new_count || 0;
// Transform to widget format
const items = messages.map((msg) => {
const senderMatch = msg.from?.match(/^([^<]+)/);
const senderName = senderMatch ? senderMatch[1].trim().replace(/"/g, '') : msg.from || 'Unknown';
const subject = msg.subject || '(No subject)';
const snippet = msg.snippet?.substring(0, 80).replace(/\s+/g, ' ').trim() || '';
return {
id: msg.id,
title: senderName,
subtitle: subject,
description: snippet,
badge: msg.is_processed === false ? 'new' : undefined,
metadata: {
thread_id: msg.thread_id,
date: msg.date,
labels: msg.labels,
},
};
});
// Build structuredContent for widget
const structuredContent = {
title: `Inbox (${total} messages${newCount > 0 ? `, ${newCount} new` : ''})`,
items,
summary: {
total,
new_count: newCount,
returned: messages.length,
},
};
// Return in OpenAI Apps SDK format
return {
content: [
{
type: 'text',
text: `Showing ${messages.length} of ${total} emails${newCount > 0 ? ` (${newCount} new)` : ''}`,
},
],
structuredContent,
_meta: getWidgetMeta(),
};
} catch (error) {
return formatError(
`Failed to load inbox: ${error instanceof Error ? error.message : String(error)}`,
500
);
}
}
// Metadata
export const metadata = {
id: 'inbox_widget',
name: 'Inbox Widget',
description: 'Display email inbox as an interactive visual widget.',
category: 'ui',
operation: 'read' as const,
};
```
---
## Calendar Widget (Calendar Pattern)
### Tool: `src/tools/meta/widgets/calendar.ts`
```typescript
/**
* Widget Tool: Calendar Widget
*
* Fetches calendar events from both work and personal calendars.
* Merges, sorts by time, and returns as widget data.
*/
import { z } from 'zod';
import { xano } from '../../../services/xano/adapter';
import { formatError } from '../../../response/formatter';
export const schema = z.object({
time_min: z.string().optional().describe('Start time in ISO format. Defaults to today'),
time_max: z.string().optional().describe('End time in ISO format. Defaults to 7 days from now'),
max_results: z.number().min(1).max(50).default(20).describe('Maximum events to return'),
});
export function getWidgetMeta() {
return {
'openai/outputTemplate': 'ui://my-mcp/calendar',
'openai/toolInvocation/invoking': 'Loading calendar...',
'openai/toolInvocation/invoked': 'Calendar ready',
'openai/widgetAccessible': true,
} as const;
}
interface CalendarEvent {
id: string;
summary: string;
start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string };
location?: string;
_account?: 'work' | 'personal';
}
export async function handler(params: z.infer<typeof schema>) {
try {
// Fetch both calendars in parallel
const [workResult, personalResult] = await Promise.all([
xano.get<{ events?: CalendarEvent[] }>('calendar/events', {
...(params.time_min && { time_min: params.time_min }),
...(params.time_max && { time_max: params.time_max }),
max_results: 50,
account: 'work',
}),
xano.get<{ events?: CalendarEvent[] }>('calendar/events', {
...(params.time_min && { time_min: params.time_min }),
...(params.time_max && { time_max: params.time_max }),
max_results: 50,
account: 'personal',
}),
]);
// Tag events with their source
const workEvents = (workResult.data?.events || []).map(e => ({ ...e, _account: 'work' as const }));
const personalEvents = (personalResult.data?.events || []).map(e => ({ ...e, _account: 'personal' as const }));
// Merge and sort by start time
const allEvents = [...workEvents, ...personalEvents].sort((a, b) => {
const aTime = new Date(a.start.dateTime || a.start.date || '').getTime();
const bTime = new Date(b.start.dateTime || b.start.date || '').getTime();
return aTime - bTime;
});
const events = allEvents.slice(0, params.max_results);
// Transform to widget format
const widgetEvents = events.map((event) => ({
id: event.id,
title: event.summary || 'Untitled Event',
start: event.start.dateTime || event.start.date || '',
end: event.end.dateTime || event.end.date || '',
location: event.location,
isAllDay: !event.start.dateTime,
badge: event._account === 'personal' ? 'personal' : 'work',
}));
const structuredContent = {
title: 'Your Calendar',
events: widgetEvents,
summary: {
total: events.length,
work_count: workEvents.length,
personal_count: personalEvents.length,
},
};
return {
content: [{ type: 'text', text: `Showing ${events.length} events` }],
structuredContent,
_meta: getWidgetMeta(),
};
} catch (error) {
return formatError(`Failed to load calendar: ${error instanceof Error ? error.message : String(error)}`, 500);
}
}
export const metadata = {
id: 'calendar_widget',
name: 'Calendar Widget',
description: 'Display calendar events as an interactive visual widget.',
category: 'ui',
operation: 'read' as const,
};
```
---
## Stats Widget (Metrics Pattern)
### Tool Pattern
```typescript
import { z } from 'zod';
import { formatError } from '../../../response/formatter';
export const schema = z.object({
category: z.enum(['freshbooks', 'actions', 'email', 'all']).default('all'),
});
export function getWidgetMeta() {
return {
'openai/outputTemplate': 'ui://my-mcp/stats',
'openai/toolInvocation/invoking': 'Loading stats...',
'openai/toolInvocation/invoked': 'Stats ready',
'openai/widgetAccessible': true,
} as const;
}
export async function handler(params: z.infer<typeof schema>) {
try {
// Fetch metrics from various sources
const metrics = {
freshbooks: { outstanding: 12500, this_month: 8200 },
actions: { pending: 5, in_progress: 2, completed_today: 8 },
email: { unread: 12, needs_response: 3 },
};
const structuredContent = {
title: 'Dashboard Stats',
metrics,
category: params.category,
};
return {
content: [{ type: 'text', text: 'Stats loaded' }],
structuredContent,
_meta: getWidgetMeta(),
};
} catch (error) {
return formatError(`Failed to load stats: ${error instanceof Error ? error.message : String(error)}`, 500);
}
}
export const metadata = {
id: 'stats_widget',
name: 'Stats Widget',
description: 'Display dashboard metrics as an interactive widget.',
category: 'ui',
operation: 'read' as const,
};
```
---
## Queue Widget (Actions Pattern)
### Tool Pattern
```typescript
import { z } from 'zod';
import { formatError } from '../../../response/formatter';
export const schema = z.object({
status: z.enum(['pending', 'processing', 'sent', 'failed', 'cancelled']).default('pending'),
limit: z.number().min(1).max(50).default(10),
});
export function getWidgetMeta() {
return {
'openai/outputTemplate': 'ui://my-mcp/queue',
'openai/toolInvocation/invoking': 'Loading queue...',
'openai/toolInvocation/invoked': 'Queue ready',
'openai/widgetAccessible': true,
} as const;
}
export async function handler(params: z.infer<typeof schema>) {
try {
// Fetch outbound queue items
const queueItems = await fetchQueueItems(params.status, params.limit);
const items = queueItems.map((item) => ({
id: item.id,
title: item.recipient,
subtitle: item.action_type,
description: item.preview,
badge: item.status,
metadata: {
scheduled_for: item.scheduled_for,
can_cancel: item.status === 'pending',
},
}));
const structuredContent = {
title: `Outbound Queue (${params.status})`,
items,
summary: { total: items.length },
};
return {
content: [{ type: 'text', text: `${items.length} items in queue` }],
structuredContent,
_meta: getWidgetMeta(),
};
} catch (error) {
return formatError(`Failed to load queue: ${error instanceof Error ? error.message : String(error)}`, 500);
}
}
export const metadata = {
id: 'queue_widget',
name: 'Queue Widget',
description: 'Display outbound queue as an interactive widget.',
category: 'ui',
operation: 'read' as const,
};
```
---
## Index.ts (Main Worker)
### Complete Example
```typescript
// src/index.ts
import { McpAgent } from 'agents/mcp';
import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { oauthHandler } from './oauth-handler';
import { setupUIResources } from './resources/ui';
import { WIDGET_LIST_JS, WIDGET_LIST_CSS } from './widgets/assets';
// Import widget tools
import * as inboxWidget from './tools/meta/widgets/inbox';
import * as calendarWidget from './tools/meta/widgets/calendar';
import * as statsWidget from './tools/meta/widgets/stats';
import * as queueWidget from './tools/meta/widgets/queue';
import * as actionsWidget from './tools/meta/widgets/actions';
// Widget assets map
const widgetAssets: Record<string, { content: string; type: string }> = {
'/widgets/my-mcp-list.js': { content: WIDGET_LIST_JS, type: 'application/javascript' },
'/widgets/my-mcp-list.css': { content: WIDGET_LIST_CSS, type: 'text/css' },
};
// Auth props interface
interface your serverAuthProps extends Record<string, unknown> {
authenticated?: boolean;
userId?: string;
}
interface your serverState {
initialized?: boolean;
}
// Main MCP Agent
class your serverMCP extends McpAgent<any, your serverState, your serverAuthProps> {
server = new McpServer({
name: 'my-mcp',
version: '1.0.0',
});
initialState: your serverState = {};
async init() {
await this.setupWidgetTools();
setupUIResources(this.server);
console.log('[your serverMCP] Initialized with widget tools');
}
private async setupWidgetTools() {
// Register inbox widget
this.server.registerTool(
inboxWidget.metadata.id,
{
title: inboxWidget.metadata.name,
description: inboxWidget.metadata.description,
inputSchema: inboxWidget.schema,
_meta: inboxWidget.getWidgetMeta(),
},
async (params) => {
const validated = inboxWidget.schema.parse(params);
return await inboxWidget.handler(validated);
}
);
// Register calendar widget
this.server.registerTool(
calendarWidget.metadata.id,
{
title: calendarWidget.metadata.name,
description: calendarWidget.metadata.description,
inputSchema: calendarWidget.schema,
_meta: calendarWidget.getWidgetMeta(),
},
async (params) => {
const validated = calendarWidget.schema.parse(params);
return await calendarWidget.handler(validated);
}
);
// ... register other widget tools similarly
}
}
// OAuth provider
const oauthProvider = new OAuthProvider({
kvNamespace: 'OAUTH_KV',
apiRoute: '/sse',
apiHandler: your serverMCP.mount('/sse'),
defaultHandler: oauthHandler,
authorizeEndpoint: '/authorize',
tokenEndpoint: '/token',
clientRegistrationEndpoint: '/register',
forceHTTPS: false,
lookupClient: async (clientId) => ({
id: clientId || 'default-client',
secret: 'client-secret',
allowed_redirects: ['*'],
name: 'your MCP Client',
}),
});
// Main worker
const mainWorker = {
async fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// Health check
if (url.pathname === '/' || url.pathname === '/health') {
return new Response('your MCP OK', { status: 200 });
}
// Static widget assets with CORS
if (url.pathname.startsWith('/widgets/')) {
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
const asset = widgetAssets[url.pathname];
if (asset) {
return new Response(asset.content, {
headers: {
'Content-Type': asset.type,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=31536000',
},
});
}
return new Response('Not found', { status: 404 });
}
// Bypass OAuth for /sse
if (url.pathname === '/sse' || url.pathname.startsWith('/sse/')) {
return your serverMCP.mount('/sse').fetch(request, env, ctx);
}
// All other routes through OAuth
return oauthProvider.fetch(request, env, ctx);
},
};
export default mainWorker;
export { your serverMCP };
```
---
## UI Resources Registration
### `src/resources/ui.ts`
```typescript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const SKYBRIDGE_MIME = 'text/html+skybridge';
const WIDGET_ASSETS_BASE = 'https://my-mcp.robertjboulos.workers.dev';
export function setupUIResources(server: McpServer): void {
// List widget resource
server.resource(
'ui-list',
'ui://my-mcp/list',
{
title: 'List Widget',
description: 'Interactive list widget for emails, items, etc.',
mimeType: SKYBRIDGE_MIME,
},
async (resourceUri) => {
const html = `<div id="widget-list-root"></div>
<link rel="stylesheet" href="${WIDGET_ASSETS_BASE}/widgets/my-mcp-list.css">
<script src="${WIDGET_ASSETS_BASE}/widgets/my-mcp-list.js"></script>`;
return {
contents: [
{
uri: resourceUri.href,
mimeType: SKYBRIDGE_MIME,
text: html,
_meta: {
'openai/widgetPrefersBorder': true,
'openai/widgetDomain': 'https://chatgpt.com',
},
},
],
};
}
);
// Calendar widget resource
server.resource(
'ui-calendar',
'ui://my-mcp/calendar',
{
title: 'Calendar Widget',
description: 'Interactive calendar widget',
mimeType: SKYBRIDGE_MIME,
},
async (resourceUri) => {
const html = `<div id="my-mcp-calendar-root"></div>
<link rel="stylesheet" href="${WIDGET_ASSETS_BASE}/widgets/my-mcp-calendar.css">
<script src="${WIDGET_ASSETS_BASE}/widgets/my-mcp-calendar.js"></script>`;
return {
contents: [
{
uri: resourceUri.href,
mimeType: SKYBRIDGE_MIME,
text: html,
_meta: {
'openai/widgetPrefersBorder': true,
'openai/widgetDomain': 'https://chatgpt.com',
},
},
],
};
}
);
// Add more widget resources as needed...
console.log('[your serverMCP] Registered UI resources');
}
```
---
## Widget Assets
### `src/widgets/assets.ts`
```typescript
export const WIDGET_LIST_JS = `
(function() {
'use strict';
var root = document.getElementById('widget-list-root');
if (!root) {
console.error('[your server List] Root element not found');
return;
}
var toolOutput = window.openai ? window.openai.toolOutput : null;
console.log('[your server List] toolOutput:', toolOutput);
if (!toolOutput) {
root.innerHTML = '<div class="my-mcp-empty">No data available</div>';
return;
}
var data = toolOutput.structuredContent || toolOutput;
var title = data.title || 'Items';
var items = data.items || [];
var summary = data.summary || {};
var subtitle = summary.total
? 'Showing ' + items.length + ' of ' + summary.total
: items.length + ' items';
var html = '<div class="my-mcp-header">';
html += '<div class="my-mcp-title">' + escapeHtml(title) + '</div>';
html += '<div class="my-mcp-subtitle">' + escapeHtml(subtitle) + '</div>';
html += '</div>';
if (items.length === 0) {
html += '<div class="my-mcp-empty">No items to display</div>';
} else {
html += '<div class="my-mcp-items">';
for (var i = 0; i < items.length; i++) {
var item = items[i];
html += '<div class="my-mcp-item" data-id="' + escapeHtml(item.id || '') + '">';
html += '<div class="my-mcp-item-icon">' + (item.icon || getDefaultIcon(item)) + '</div>';
html += '<div class="my-mcp-item-content">';
html += '<div class="my-mcp-item-title">' + escapeHtml(item.title || '') + '</div>';
if (item.subtitle) {
html += '<div class="my-mcp-item-subtitle">' + escapeHtml(item.subtitle) + '</div>';
}
if (item.description) {
html += '<div class="my-mcp-item-desc">' + escapeHtml(item.description) + '</div>';
}
html += '</div>';
if (item.badge) {
html += '<div class="my-mcp-badge">' + escapeHtml(item.badge) + '</div>';
}
html += '</div>';
}
html += '</div>';
}
root.innerHTML = html;
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function getDefaultIcon(item) {
return '📧';
}
})();
`.trim();
export const WIDGET_LIST_CSS = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #ffffff;
padding: 16px;
}
.my-mcp-header { margin-bottom: 16px; }
.my-mcp-title { font-size: 18px; font-weight: 600; color: #1f2937; }
.my-mcp-subtitle { font-size: 13px; color: #6b7280; margin-top: 2px; }
.my-mcp-items { max-height: 400px; overflow-y: auto; }
.my-mcp-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #e5e7eb;
cursor: pointer;
transition: background 150ms ease;
}
.my-mcp-item:hover { background: #f9fafb; }
.my-mcp-item:last-child { border-bottom: none; }
.my-mcp-item-icon {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 50%;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.my-mcp-item-content { flex: 1; min-width: 0; }
.my-mcp-item-title {
font-weight: 500;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.my-mcp-item-subtitle {
font-size: 13px;
color: #4b5563;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.my-mcp-item-desc {
font-size: 12px;
color: #9ca3af;
margin-top: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.my-mcp-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
background: #3b82f6;
color: white;
text-transform: uppercase;
}
.my-mcp-empty { padding: 32px; text-align: center; color: #9ca3af; }
`.trim();
```
---
## Key Takeaways
1. **Tool returns data** - `structuredContent` with items/events/metrics
2. **Tool points to resource** - `_meta["openai/outputTemplate"]` = resource URI
3. **Resource serves template** - Minimal HTML with `text/html+skybridge`
4. **Template loads external assets** - JS/CSS from `/widgets/*` endpoints
5. **Widget JS reads data** - `window.openai.toolOutput` contains `structuredContent`
6. **Worker serves assets** - With CORS headers for ChatGPT sandbox
---
## Next Steps
- See [quickstart.md](quickstart.md) to build from scratch
- See [cdn-assets.md](cdn-assets.md) for asset hosting details
- See [architecture.md](architecture.md) for OAuth deep dive
Widgets are NOT passive displays - they can trigger actions via window.openai. The host injects this global object with methods for calling tools, sending messages, managing state, and controlling display.
| Method/Property | Type | Purpose |
|---|---|---|
toolInput |
Read | Arguments supplied when tool was invoked |
toolOutput |
Read | structuredContent returned from MCP tool |
toolResponseMetadata |
Read | _meta payload (not shown to model) |
widgetState |
Read | Persisted UI state from previous render |
setWidgetState(state) |
Write | Persist UI state (< 4k tokens) |
callTool(name, args) |
Action | Invoke another MCP tool |
sendFollowUpMessage({ prompt }) |
Action | Post message as if user typed it |
requestClose() |
Action | Close the widget |
requestDisplayMode({ mode }) |
Action | Request inline/pip/fullscreen |
uploadFile(file) |
Action | Upload image file |
getFileDownloadUrl({ fileId }) |
Action | Get temp download URL |
openExternal({ href }) |
Action | Open link in user's browser |
setOpenInAppUrl({ href }) |
Action | Set "Open in App" button URL |
theme |
Read | Current theme setting |
displayMode |
Read | Current layout mode |
locale |
Read | Language/region identifier |
javascriptvar toolOutput = window.openai ? window.openai.toolOutput : null;
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data</div>';
return;
}
// Data structure: { structuredContent: {...} } or direct object
var data = toolOutput.structuredContent || toolOutput;
var items = data.items || [];
javascriptvar toolInput = window.openai?.toolInput || {};
var searchQuery = toolInput.query; // What user originally searched for
javascript// Restore state from previous render
var state = window.openai?.widgetState || {
activeTab: 'entities',
expandedItems: {},
selectedId: null
};
Invoke another MCP tool directly from the widget!
javascriptfunction callyour server(toolId, args) {
if (window.openai?.callTool) {
window.openai.callTool('my_execute', {
tool_id: toolId,
arguments: args
}).then(function(result) {
console.log('[Widget] Tool result:', result);
// result contains the new structuredContent
}).catch(function(err) {
console.error('[Widget] Tool error:', err);
});
}
}
// Example: Get entity details
button.addEventListener('click', function() {
var entityId = this.getAttribute('data-id');
callyour server('kg_entity', { entity_id: parseInt(entityId) });
});
Ask ChatGPT to post a message as if the user typed it.
javascriptfunction suggestMerge(entityA, entityB) {
if (window.openai?.sendFollowUpMessage) {
window.openai.sendFollowUpMessage({
prompt: 'Merge entity "' + entityA.name + '" with "' + entityB.name + '"'
});
}
}
// Example: Duplicate detection → suggest merge
var duplicateBtn = root.querySelector('.merge-btn');
duplicateBtn.addEventListener('click', function() {
var msg = 'Found possible duplicates: "Robert Boulos" and "Rob B". Merge them?';
window.openai.sendFollowUpMessage({ prompt: msg });
});
Store UI state that persists across renders.
javascriptvar state = window.openai?.widgetState || {
activeTab: null,
expandedTabs: {},
selectedItem: null,
view: 'list'
};
function saveState() {
if (window.openai?.setWidgetState) {
window.openai.setWidgetState(state);
}
}
// Update state on interaction
tabButton.addEventListener('click', function() {
state.activeTab = this.getAttribute('data-tab');
saveState(); // Persist immediately
render(); // Re-render UI
});
javascript// Request fullscreen for complex UI
window.openai?.requestDisplayMode({ mode: 'fullscreen' });
// Modes: 'inline', 'pip' (picture-in-picture), 'fullscreen'
// Note: On mobile, PiP may be coerced to fullscreen
javascript// Close widget after action complete
function onActionComplete() {
if (window.openai?.requestClose) {
window.openai.requestClose();
}
}
javascript// Set destination for "Open in App" button (fullscreen mode)
window.openai?.setOpenInAppUrl({ href: 'https://myapp.com/dashboard' });
javascript// Upload user-selected image
var input = document.getElementById('file-input');
input.addEventListener('change', async function() {
var file = this.files[0];
if (file && window.openai?.uploadFile) {
var result = await window.openai.uploadFile(file);
console.log('File ID:', result.fileId);
}
});
// Supports: image/png, image/jpeg, image/webp
javascript// Get temp URL to display uploaded file
if (window.openai?.getFileDownloadUrl) {
var result = await window.openai.getFileDownloadUrl({ fileId: 'file-123' });
img.src = result.url;
}
javascript// Open vetted link in user's browser
window.openai?.openExternal({ href: 'https://docs.example.com/guide' });
The widget doesn't just display - it surfaces insights and enables actions.
┌─────────────────────────────────────────────────────────────────┐
│ Human-in-the-Loop Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. TOOL returns data with insights │
│ ↓ │
│ 2. WIDGET analyzes data (e.g., finds duplicates) │
│ ↓ │
│ 3. WIDGET shows alert: "Possible duplicates found" │
│ ↓ │
│ 4. USER clicks "Review" or item │
│ ↓ │
│ 5. WIDGET calls: │
│ - callTool() for direct actions │
│ - sendFollowUpMessage() for ChatGPT decisions │
│ ↓ │
│ 6. ACTION executes with rationale │
│ │
└─────────────────────────────────────────────────────────────────┘
javascript// Entity click → get full details
item.addEventListener('click', function() {
var id = this.getAttribute('data-id');
window.openai.callTool('my_execute', {
tool_id: 'kg_entity',
arguments: { entity_id: parseInt(id) }
});
});
javascript// Found duplicates → ask user to confirm merge
var duplicates = findDuplicates(entities);
if (duplicates.length > 0) {
alertBtn.addEventListener('click', function() {
var d = duplicates[0];
window.openai.sendFollowUpMessage({
prompt: 'Found duplicates: "' + d.a.name + '" and "' + d.b.name + '". Merge them?'
});
});
}
javascript// Execute tool with user-provided rationale
confirmBtn.addEventListener('click', function() {
var toolId = this.getAttribute('data-tool');
var rationale = document.getElementById('rationale-input').value;
if (!rationale.trim()) {
alert('Please provide a rationale');
return;
}
window.openai.sendFollowUpMessage({
prompt: 'Execute ' + toolId + ' with rationale: ' + rationale
});
});
javascript(function() {
'use strict';
var root = document.getElementById('widget-root');
if (!root) return;
var toolOutput = window.openai?.toolOutput;
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data</div>';
return;
}
var data = toolOutput.structuredContent || toolOutput;
// Restore persisted state
var state = window.openai?.widgetState || {
activeTab: 'entities',
expandedTabs: {},
selectedItem: null,
view: 'list'
};
// Find duplicates in entities
var duplicates = findDuplicates(data.sections);
render();
function saveState() {
if (window.openai?.setWidgetState) {
window.openai.setWidgetState(state);
}
}
function findDuplicates(sections) {
var entitySection = sections.find(function(s) { return s.id === 'entities'; });
if (!entitySection) return [];
var items = entitySection.items || [];
var found = [];
for (var i = 0; i < items.length; i++) {
for (var j = i + 1; j < items.length; j++) {
var nameA = (items[i].name || '').toLowerCase();
var nameB = (items[j].name || '').toLowerCase();
if (nameA && nameB && (nameA.includes(nameB) || nameB.includes(nameA))) {
found.push({ a: items[i], b: items[j] });
}
}
}
return found;
}
function render() {
var html = '<div class="dashboard">';
// Duplicate alert
if (duplicates.length > 0) {
html += '<div class="alert">';
html += '<span>⚠️ ' + duplicates.length + ' possible duplicates</span>';
html += '<button class="review-btn">Review</button>';
html += '</div>';
}
// ... render tabs, items, etc.
html += '</div>';
root.innerHTML = html;
attachHandlers();
}
function attachHandlers() {
// Tab clicks
var tabs = root.querySelectorAll('.tab');
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
state.activeTab = this.getAttribute('data-tab');
saveState();
render();
});
});
// Item clicks → show detail
var items = root.querySelectorAll('.item');
items.forEach(function(item) {
item.addEventListener('click', function() {
var id = this.getAttribute('data-id');
// Call tool to get full details
if (window.openai?.callTool) {
window.openai.callTool('my_execute', {
tool_id: 'kg_entity',
arguments: { entity_id: parseInt(id) }
});
}
});
});
// Review duplicates button
var reviewBtn = root.querySelector('.review-btn');
if (reviewBtn) {
reviewBtn.addEventListener('click', function() {
if (duplicates.length > 0 && window.openai?.sendFollowUpMessage) {
var d = duplicates[0];
window.openai.sendFollowUpMessage({
prompt: 'Found duplicate entities: "' + d.a.name + '" (ID: ' + d.a.id + ') and "' + d.b.name + '" (ID: ' + d.b.id + '). Should I merge them?'
});
}
});
}
}
})();
# Interactive Widgets - Human-in-the-Loop Patterns
## Table of Contents
- [The window.openai API](#the-windowopenai-api)
- [Reading Data](#reading-data)
- [Calling Tools](#calling-tools)
- [Sending Follow-Up Messages](#sending-follow-up-messages)
- [State Persistence](#state-persistence)
- [Display Control](#display-control)
- [File Operations](#file-operations)
- [Navigation & External Links](#navigation--external-links)
- [Human-in-the-Loop Pattern](#human-in-the-loop-pattern)
- [Complete Interactive Example](#complete-interactive-example)
---
## The window.openai API
Widgets are NOT passive displays - they can **trigger actions** via `window.openai`. The host injects this global object with methods for calling tools, sending messages, managing state, and controlling display.
### Full API Reference
| Method/Property | Type | Purpose |
|-----------------|------|---------|
| `toolInput` | Read | Arguments supplied when tool was invoked |
| `toolOutput` | Read | structuredContent returned from MCP tool |
| `toolResponseMetadata` | Read | `_meta` payload (not shown to model) |
| `widgetState` | Read | Persisted UI state from previous render |
| `setWidgetState(state)` | Write | Persist UI state (< 4k tokens) |
| `callTool(name, args)` | Action | Invoke another MCP tool |
| `sendFollowUpMessage({ prompt })` | Action | Post message as if user typed it |
| `requestClose()` | Action | Close the widget |
| `requestDisplayMode({ mode })` | Action | Request inline/pip/fullscreen |
| `uploadFile(file)` | Action | Upload image file |
| `getFileDownloadUrl({ fileId })` | Action | Get temp download URL |
| `openExternal({ href })` | Action | Open link in user's browser |
| `setOpenInAppUrl({ href })` | Action | Set "Open in App" button URL |
| `theme` | Read | Current theme setting |
| `displayMode` | Read | Current layout mode |
| `locale` | Read | Language/region identifier |
---
## Reading Data
### toolOutput - Data from MCP Tool
```javascript
var toolOutput = window.openai ? window.openai.toolOutput : null;
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data</div>';
return;
}
// Data structure: { structuredContent: {...} } or direct object
var data = toolOutput.structuredContent || toolOutput;
var items = data.items || [];
```
### toolInput - Original Tool Arguments
```javascript
var toolInput = window.openai?.toolInput || {};
var searchQuery = toolInput.query; // What user originally searched for
```
### widgetState - Persisted UI State
```javascript
// Restore state from previous render
var state = window.openai?.widgetState || {
activeTab: 'entities',
expandedItems: {},
selectedId: null
};
```
---
## Calling Tools
### window.openai.callTool(name, args)
**Invoke another MCP tool directly from the widget!**
```javascript
function callyour server(toolId, args) {
if (window.openai?.callTool) {
window.openai.callTool('my_execute', {
tool_id: toolId,
arguments: args
}).then(function(result) {
console.log('[Widget] Tool result:', result);
// result contains the new structuredContent
}).catch(function(err) {
console.error('[Widget] Tool error:', err);
});
}
}
// Example: Get entity details
button.addEventListener('click', function() {
var entityId = this.getAttribute('data-id');
callyour server('kg_entity', { entity_id: parseInt(entityId) });
});
```
### Requirements for callTool
- Tool must be marked as component-initiated
- Design tools to be idempotent where possible
- Returns Promise with updated structuredContent
---
## Sending Follow-Up Messages
### window.openai.sendFollowUpMessage({ prompt })
**Ask ChatGPT to post a message as if the user typed it.**
```javascript
function suggestMerge(entityA, entityB) {
if (window.openai?.sendFollowUpMessage) {
window.openai.sendFollowUpMessage({
prompt: 'Merge entity "' + entityA.name + '" with "' + entityB.name + '"'
});
}
}
// Example: Duplicate detection → suggest merge
var duplicateBtn = root.querySelector('.merge-btn');
duplicateBtn.addEventListener('click', function() {
var msg = 'Found possible duplicates: "Robert Boulos" and "Rob B". Merge them?';
window.openai.sendFollowUpMessage({ prompt: msg });
});
```
### Use Cases
- Suggest actions based on widget analysis
- Request clarification from user
- Trigger complex multi-step workflows
- Hand off to ChatGPT for decisions
---
## State Persistence
### window.openai.setWidgetState(state)
**Store UI state that persists across renders.**
```javascript
var state = window.openai?.widgetState || {
activeTab: null,
expandedTabs: {},
selectedItem: null,
view: 'list'
};
function saveState() {
if (window.openai?.setWidgetState) {
window.openai.setWidgetState(state);
}
}
// Update state on interaction
tabButton.addEventListener('click', function() {
state.activeTab = this.getAttribute('data-tab');
saveState(); // Persist immediately
render(); // Re-render UI
});
```
### Important Notes
- State must be < 4k tokens (serializable JSON)
- State is exposed to the model for reasoning
- Scoped to individual widget instances per message
- Call after every meaningful UI interaction
---
## Display Control
### requestDisplayMode({ mode })
```javascript
// Request fullscreen for complex UI
window.openai?.requestDisplayMode({ mode: 'fullscreen' });
// Modes: 'inline', 'pip' (picture-in-picture), 'fullscreen'
// Note: On mobile, PiP may be coerced to fullscreen
```
### requestClose()
```javascript
// Close widget after action complete
function onActionComplete() {
if (window.openai?.requestClose) {
window.openai.requestClose();
}
}
```
### setOpenInAppUrl({ href })
```javascript
// Set destination for "Open in App" button (fullscreen mode)
window.openai?.setOpenInAppUrl({ href: 'https://myapp.com/dashboard' });
```
---
## File Operations
### uploadFile(file)
```javascript
// Upload user-selected image
var input = document.getElementById('file-input');
input.addEventListener('change', async function() {
var file = this.files[0];
if (file && window.openai?.uploadFile) {
var result = await window.openai.uploadFile(file);
console.log('File ID:', result.fileId);
}
});
// Supports: image/png, image/jpeg, image/webp
```
### getFileDownloadUrl({ fileId })
```javascript
// Get temp URL to display uploaded file
if (window.openai?.getFileDownloadUrl) {
var result = await window.openai.getFileDownloadUrl({ fileId: 'file-123' });
img.src = result.url;
}
```
---
## Navigation & External Links
### openExternal({ href })
```javascript
// Open vetted link in user's browser
window.openai?.openExternal({ href: 'https://docs.example.com/guide' });
```
### Router Integration
- Use standard routing APIs (React Router)
- Host mirrors iframe history into ChatGPT UI
---
## Human-in-the-Loop Pattern
**The widget doesn't just display - it surfaces insights and enables actions.**
```
┌─────────────────────────────────────────────────────────────────┐
│ Human-in-the-Loop Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. TOOL returns data with insights │
│ ↓ │
│ 2. WIDGET analyzes data (e.g., finds duplicates) │
│ ↓ │
│ 3. WIDGET shows alert: "Possible duplicates found" │
│ ↓ │
│ 4. USER clicks "Review" or item │
│ ↓ │
│ 5. WIDGET calls: │
│ - callTool() for direct actions │
│ - sendFollowUpMessage() for ChatGPT decisions │
│ ↓ │
│ 6. ACTION executes with rationale │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Pattern 1: Direct Tool Call
```javascript
// Entity click → get full details
item.addEventListener('click', function() {
var id = this.getAttribute('data-id');
window.openai.callTool('my_execute', {
tool_id: 'kg_entity',
arguments: { entity_id: parseInt(id) }
});
});
```
### Pattern 2: Suggest to ChatGPT
```javascript
// Found duplicates → ask user to confirm merge
var duplicates = findDuplicates(entities);
if (duplicates.length > 0) {
alertBtn.addEventListener('click', function() {
var d = duplicates[0];
window.openai.sendFollowUpMessage({
prompt: 'Found duplicates: "' + d.a.name + '" and "' + d.b.name + '". Merge them?'
});
});
}
```
### Pattern 3: Action with Rationale
```javascript
// Execute tool with user-provided rationale
confirmBtn.addEventListener('click', function() {
var toolId = this.getAttribute('data-tool');
var rationale = document.getElementById('rationale-input').value;
if (!rationale.trim()) {
alert('Please provide a rationale');
return;
}
window.openai.sendFollowUpMessage({
prompt: 'Execute ' + toolId + ' with rationale: ' + rationale
});
});
```
---
## Complete Interactive Example
### Dashboard Widget with Full Interactivity
```javascript
(function() {
'use strict';
var root = document.getElementById('widget-root');
if (!root) return;
var toolOutput = window.openai?.toolOutput;
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data</div>';
return;
}
var data = toolOutput.structuredContent || toolOutput;
// Restore persisted state
var state = window.openai?.widgetState || {
activeTab: 'entities',
expandedTabs: {},
selectedItem: null,
view: 'list'
};
// Find duplicates in entities
var duplicates = findDuplicates(data.sections);
render();
function saveState() {
if (window.openai?.setWidgetState) {
window.openai.setWidgetState(state);
}
}
function findDuplicates(sections) {
var entitySection = sections.find(function(s) { return s.id === 'entities'; });
if (!entitySection) return [];
var items = entitySection.items || [];
var found = [];
for (var i = 0; i < items.length; i++) {
for (var j = i + 1; j < items.length; j++) {
var nameA = (items[i].name || '').toLowerCase();
var nameB = (items[j].name || '').toLowerCase();
if (nameA && nameB && (nameA.includes(nameB) || nameB.includes(nameA))) {
found.push({ a: items[i], b: items[j] });
}
}
}
return found;
}
function render() {
var html = '<div class="dashboard">';
// Duplicate alert
if (duplicates.length > 0) {
html += '<div class="alert">';
html += '<span>⚠️ ' + duplicates.length + ' possible duplicates</span>';
html += '<button class="review-btn">Review</button>';
html += '</div>';
}
// ... render tabs, items, etc.
html += '</div>';
root.innerHTML = html;
attachHandlers();
}
function attachHandlers() {
// Tab clicks
var tabs = root.querySelectorAll('.tab');
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
state.activeTab = this.getAttribute('data-tab');
saveState();
render();
});
});
// Item clicks → show detail
var items = root.querySelectorAll('.item');
items.forEach(function(item) {
item.addEventListener('click', function() {
var id = this.getAttribute('data-id');
// Call tool to get full details
if (window.openai?.callTool) {
window.openai.callTool('my_execute', {
tool_id: 'kg_entity',
arguments: { entity_id: parseInt(id) }
});
}
});
});
// Review duplicates button
var reviewBtn = root.querySelector('.review-btn');
if (reviewBtn) {
reviewBtn.addEventListener('click', function() {
if (duplicates.length > 0 && window.openai?.sendFollowUpMessage) {
var d = duplicates[0];
window.openai.sendFollowUpMessage({
prompt: 'Found duplicate entities: "' + d.a.name + '" (ID: ' + d.a.id + ') and "' + d.b.name + '" (ID: ' + d.b.id + '). Should I merge them?'
});
}
});
}
}
})();
```
---
## Sources
- [OpenAI Apps SDK - Build your ChatGPT UI](https://developers.openai.com/apps-sdk/build/chatgpt-ui/)
- [OpenAI Apps SDK - Reference](https://developers.openai.com/apps-sdk/reference/)
- [OpenAI Apps SDK - State Management](https://developers.openai.com/apps-sdk/build/state-management/)
- Production implementation: my-mcp dashboard widget
---
## Next Steps
- See [widgets.md](widgets.md) for basic widget patterns
- See [examples.md](examples.md) for more complete implementations
npm install -g wranglerwrangler loginbash# Create project directory
mkdir my-widget-mcp
cd my-widget-mcp
# Initialize npm project
npm init -y
# Install dependencies
npm install @cloudflare/workers-oauth-provider @modelcontextprotocol/sdk agents hono zod
# Install dev dependencies
npm install -D typescript wrangler @types/node
Create TypeScript config:
json// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"types": ["@cloudflare/workers-types"]
},
"include": ["src/**/*"]
}
jsonc// wrangler.jsonc
{
"name": "my-widget-mcp",
"main": "src/index.ts",
"compatibility_date": "2025-03-10",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [
{
"name": "MCP_OBJECT",
"class_name": "MyWidgetMCP"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyWidgetMCP"]
}
],
"kv_namespaces": [
{
"binding": "OAUTH_KV",
"id": "YOUR_KV_ID_HERE"
}
]
}
bash# Create KV namespace for OAuth tokens
wrangler kv:namespace create "OAUTH_KV"
# Copy the ID from the output and paste into wrangler.jsonc
# Example output:
# { binding = "OAUTH_KV", id = "abc123def456..." }
typescript// src/mcp-agent.ts
import { McpAgent } from 'agents/mcp';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { setupUIResources } from './resources/ui';
import { WIDGET_LIST_JS, WIDGET_LIST_CSS } from './widgets/assets';
interface MyAuthProps extends Record<string, unknown> {
authenticated?: boolean;
userId?: string;
}
interface MyState {
initialized?: boolean;
}
export class MyWidgetMCP extends McpAgent<any, MyState, MyAuthProps> {
server = new McpServer({
name: 'my-widget-mcp',
version: '1.0.0',
});
initialState: MyState = {};
async init() {
await this.setupTools();
setupUIResources(this.server);
}
private async setupTools() {
// Register a widget tool
this.server.registerTool(
'items_widget',
{
title: 'Items Widget',
description: 'Display items as an interactive widget',
inputSchema: z.object({
limit: z.number().min(1).max(50).default(20).describe('Max items'),
}),
_meta: {
'openai/outputTemplate': 'ui://mymcp/list',
'openai/toolInvocation/invoking': 'Loading items...',
'openai/toolInvocation/invoked': 'Items ready',
'openai/widgetAccessible': true,
},
},
async (params) => {
// Fetch your data here
const items = [
{ id: '1', title: 'Item 1', subtitle: 'Description 1', icon: '📦' },
{ id: '2', title: 'Item 2', subtitle: 'Description 2', icon: '📦' },
{ id: '3', title: 'Item 3', subtitle: 'Description 3', icon: '📦' },
];
const structuredContent = {
title: `Items (${items.length})`,
items,
summary: { total: items.length, returned: items.length },
};
return {
content: [{ type: 'text', text: `Showing ${items.length} items` }],
structuredContent,
_meta: {
'openai/outputTemplate': 'ui://mymcp/list',
'openai/toolInvocation/invoking': 'Loading items...',
'openai/toolInvocation/invoked': 'Items ready',
'openai/widgetAccessible': true,
},
};
}
);
}
}
export { MyWidgetMCP };
typescript// src/oauth-handler.ts
import { Hono } from 'hono';
const app = new Hono();
// GET /authorize - Show approval dialog
app.get('/authorize', async (c) => {
const oauthReq = c.get('oauthRequest');
// Simple auto-approve (for development)
// In production, show a proper approval UI
return c.html(`
<!DOCTYPE html>
<html>
<head>
<title>Authorize MCP</title>
<style>
body { font-family: system-ui; padding: 40px; text-align: center; }
button { padding: 12px 24px; font-size: 16px; cursor: pointer; }
</style>
</head>
<body>
<h1>Authorize My Widget MCP</h1>
<p>Click to authorize access.</p>
<form method="POST" action="/authorize">
<input type="hidden" name="oauthRequest" value='${JSON.stringify(oauthReq)}' />
<button type="submit">Authorize</button>
</form>
</body>
</html>
`);
});
// POST /authorize - Complete authorization
app.post('/authorize', async (c) => {
const body = await c.req.parseBody();
const oauthReq = JSON.parse(body.oauthRequest as string);
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
request: oauthReq,
userId: 'user-' + Date.now(),
props: {
authenticated: true,
},
});
return Response.redirect(redirectTo);
});
export const oauthHandler = app;
Already done in Step 4. The key parts:
structuredContent - JSON data for the widget_meta with openai/outputTemplate pointing to resource URIcontent with text fallback for non-widget contextstypescript// src/resources/ui.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const SKYBRIDGE_MIME = 'text/html+skybridge';
const ASSETS_BASE = 'https://my-widget-mcp.YOUR_SUBDOMAIN.workers.dev';
export function setupUIResources(server: McpServer): void {
server.resource(
'ui-list',
'ui://mymcp/list',
{
title: 'List Widget',
description: 'Interactive list widget',
mimeType: SKYBRIDGE_MIME,
},
async (resourceUri) => {
const html = `<div id="widget-root"></div>
<link rel="stylesheet" href="${ASSETS_BASE}/widgets/list.css">
<script src="${ASSETS_BASE}/widgets/list.js"></script>`;
return {
contents: [
{
uri: resourceUri.href,
mimeType: SKYBRIDGE_MIME,
text: html,
_meta: {
'openai/widgetPrefersBorder': true,
'openai/widgetDomain': 'https://chatgpt.com',
},
},
],
};
}
);
}
typescript// src/widgets/assets.ts
export const WIDGET_LIST_JS = `
(function() {
'use strict';
var root = document.getElementById('widget-root');
if (!root) {
console.error('[Widget] Root element not found');
return;
}
var toolOutput = window.openai ? window.openai.toolOutput : null;
console.log('[Widget] toolOutput:', toolOutput);
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data available</div>';
return;
}
var data = toolOutput.structuredContent || toolOutput;
var title = data.title || 'Items';
var items = data.items || [];
var html = '<div class="header">' + escapeHtml(title) + '</div>';
if (items.length === 0) {
html += '<div class="empty">No items</div>';
} else {
html += '<div class="items">';
for (var i = 0; i < items.length; i++) {
var item = items[i];
html += '<div class="item">';
html += '<div class="icon">' + (item.icon || '📄') + '</div>';
html += '<div class="content">';
html += '<div class="title">' + escapeHtml(item.title || '') + '</div>';
if (item.subtitle) {
html += '<div class="subtitle">' + escapeHtml(item.subtitle) + '</div>';
}
html += '</div>';
html += '</div>';
}
html += '</div>';
}
root.innerHTML = html;
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
})();
`.trim();
export const WIDGET_LIST_CSS = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
color: #1f2937;
background: #fff;
padding: 16px;
}
.header { font-size: 18px; font-weight: 600; margin-bottom: 16px; }
.items { max-height: 400px; overflow-y: auto; }
.item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #e5e7eb;
}
.item:hover { background: #f9fafb; }
.item:last-child { border-bottom: none; }
.icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.content { flex: 1; min-width: 0; }
.title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtitle { font-size: 13px; color: #6b7280; margin-top: 2px; }
.empty { padding: 32px; text-align: center; color: #9ca3af; }
`.trim();
typescript// src/index.ts
import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
import { MyWidgetMCP } from './mcp-agent';
import { oauthHandler } from './oauth-handler';
import { WIDGET_LIST_JS, WIDGET_LIST_CSS } from './widgets/assets';
// Widget assets map
const widgetAssets: Record<string, { content: string; type: string }> = {
'/widgets/list.js': { content: WIDGET_LIST_JS, type: 'application/javascript' },
'/widgets/list.css': { content: WIDGET_LIST_CSS, type: 'text/css' },
};
// OAuth provider configuration
const oauthProvider = new OAuthProvider({
kvNamespace: 'OAUTH_KV',
apiRoute: '/sse',
apiHandler: MyWidgetMCP.mount('/sse'),
defaultHandler: oauthHandler,
authorizeEndpoint: '/authorize',
tokenEndpoint: '/token',
clientRegistrationEndpoint: '/register',
forceHTTPS: false,
lookupClient: async (clientId) => ({
id: clientId || 'default-client',
secret: 'client-secret',
allowed_redirects: ['*'],
name: 'MCP Client',
}),
});
// Main worker export
const mainWorker = {
async fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// Health check
if (url.pathname === '/' || url.pathname === '/health') {
return new Response('OK', { status: 200 });
}
// Static widget assets with CORS
if (url.pathname.startsWith('/widgets/')) {
// Handle CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
const asset = widgetAssets[url.pathname];
if (asset) {
return new Response(asset.content, {
headers: {
'Content-Type': asset.type,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=31536000',
},
});
}
return new Response('Not found', { status: 404 });
}
// Bypass OAuth for /sse (direct MCP access)
if (url.pathname === '/sse' || url.pathname.startsWith('/sse/')) {
return MyWidgetMCP.mount('/sse').fetch(request, env, ctx);
}
// All other routes through OAuth
return oauthProvider.fetch(request, env, ctx);
},
};
export default mainWorker;
export { MyWidgetMCP };
bash# Add build script to package.json
# "scripts": { "build": "tsc", "deploy": "wrangler deploy" }
# Build
npm run build
# Deploy
npm run deploy
You should see output like:
Deployed my-widget-mcp to https://my-widget-mcp.YOUR_SUBDOMAIN.workers.dev
bash# Check health
curl https://my-widget-mcp.YOUR_SUBDOMAIN.workers.dev/
# Check widget assets
curl https://my-widget-mcp.YOUR_SUBDOMAIN.workers.dev/widgets/list.js
curl https://my-widget-mcp.YOUR_SUBDOMAIN.workers.dev/widgets/list.css
https://my-widget-mcp.YOUR_SUBDOMAIN.workers.dev/sse/authorize endpoint Show me the items widget
or
Use items_widget to display items
After completing all steps:
my-widget-mcp/
├── src/
│ ├── index.ts ← Main worker entry
│ ├── mcp-agent.ts ← McpAgent with tools
│ ├── oauth-handler.ts ← OAuth approval UI
│ ├── resources/
│ │ └── ui.ts ← UI resources (text/html+skybridge)
│ └── widgets/
│ └── assets.ts ← Widget JS/CSS as strings
├── wrangler.jsonc ← Cloudflare config
├── tsconfig.json ← TypeScript config
└── package.json
# Quickstart: Build an MCP with ChatGPT Apps Widgets
## Table of Contents
- [Prerequisites](#prerequisites)
- [Step 1: Clone the Template](#step-1-clone-the-template)
- [Step 2: Configure Wrangler](#step-2-configure-wrangler)
- [Step 3: Create KV Namespace](#step-3-create-kv-namespace)
- [Step 4: Implement the McpAgent](#step-4-implement-the-mcpagent)
- [Step 5: Add OAuth Handler](#step-5-add-oauth-handler)
- [Step 6: Create a Widget Tool](#step-6-create-a-widget-tool)
- [Step 7: Register UI Resources](#step-7-register-ui-resources)
- [Step 8: Add Widget Assets](#step-8-add-widget-assets)
- [Step 9: Wire Up the Main Worker](#step-9-wire-up-the-main-worker)
- [Step 10: Build and Deploy](#step-10-build-and-deploy)
- [Step 11: Test in ChatGPT](#step-11-test-in-chatgpt)
---
## Prerequisites
- Node.js 18+
- Cloudflare account with Workers enabled
- Wrangler CLI installed: `npm install -g wrangler`
- Logged into Wrangler: `wrangler login`
---
## Step 1: Clone the Template
```bash
# Create project directory
mkdir my-widget-mcp
cd my-widget-mcp
# Initialize npm project
npm init -y
# Install dependencies
npm install @cloudflare/workers-oauth-provider @modelcontextprotocol/sdk agents hono zod
# Install dev dependencies
npm install -D typescript wrangler @types/node
```
Create TypeScript config:
```json
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"types": ["@cloudflare/workers-types"]
},
"include": ["src/**/*"]
}
```
---
## Step 2: Configure Wrangler
```jsonc
// wrangler.jsonc
{
"name": "my-widget-mcp",
"main": "src/index.ts",
"compatibility_date": "2025-03-10",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [
{
"name": "MCP_OBJECT",
"class_name": "MyWidgetMCP"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyWidgetMCP"]
}
],
"kv_namespaces": [
{
"binding": "OAUTH_KV",
"id": "YOUR_KV_ID_HERE"
}
]
}
```
---
## Step 3: Create KV Namespace
```bash
# Create KV namespace for OAuth tokens
wrangler kv:namespace create "OAUTH_KV"
# Copy the ID from the output and paste into wrangler.jsonc
# Example output:
# { binding = "OAUTH_KV", id = "abc123def456..." }
```
---
## Step 4: Implement the McpAgent
```typescript
// src/mcp-agent.ts
import { McpAgent } from 'agents/mcp';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { setupUIResources } from './resources/ui';
import { WIDGET_LIST_JS, WIDGET_LIST_CSS } from './widgets/assets';
interface MyAuthProps extends Record<string, unknown> {
authenticated?: boolean;
userId?: string;
}
interface MyState {
initialized?: boolean;
}
export class MyWidgetMCP extends McpAgent<any, MyState, MyAuthProps> {
server = new McpServer({
name: 'my-widget-mcp',
version: '1.0.0',
});
initialState: MyState = {};
async init() {
await this.setupTools();
setupUIResources(this.server);
}
private async setupTools() {
// Register a widget tool
this.server.registerTool(
'items_widget',
{
title: 'Items Widget',
description: 'Display items as an interactive widget',
inputSchema: z.object({
limit: z.number().min(1).max(50).default(20).describe('Max items'),
}),
_meta: {
'openai/outputTemplate': 'ui://mymcp/list',
'openai/toolInvocation/invoking': 'Loading items...',
'openai/toolInvocation/invoked': 'Items ready',
'openai/widgetAccessible': true,
},
},
async (params) => {
// Fetch your data here
const items = [
{ id: '1', title: 'Item 1', subtitle: 'Description 1', icon: '📦' },
{ id: '2', title: 'Item 2', subtitle: 'Description 2', icon: '📦' },
{ id: '3', title: 'Item 3', subtitle: 'Description 3', icon: '📦' },
];
const structuredContent = {
title: `Items (${items.length})`,
items,
summary: { total: items.length, returned: items.length },
};
return {
content: [{ type: 'text', text: `Showing ${items.length} items` }],
structuredContent,
_meta: {
'openai/outputTemplate': 'ui://mymcp/list',
'openai/toolInvocation/invoking': 'Loading items...',
'openai/toolInvocation/invoked': 'Items ready',
'openai/widgetAccessible': true,
},
};
}
);
}
}
export { MyWidgetMCP };
```
---
## Step 5: Add OAuth Handler
```typescript
// src/oauth-handler.ts
import { Hono } from 'hono';
const app = new Hono();
// GET /authorize - Show approval dialog
app.get('/authorize', async (c) => {
const oauthReq = c.get('oauthRequest');
// Simple auto-approve (for development)
// In production, show a proper approval UI
return c.html(`
<!DOCTYPE html>
<html>
<head>
<title>Authorize MCP</title>
<style>
body { font-family: system-ui; padding: 40px; text-align: center; }
button { padding: 12px 24px; font-size: 16px; cursor: pointer; }
</style>
</head>
<body>
<h1>Authorize My Widget MCP</h1>
<p>Click to authorize access.</p>
<form method="POST" action="/authorize">
<input type="hidden" name="oauthRequest" value='${JSON.stringify(oauthReq)}' />
<button type="submit">Authorize</button>
</form>
</body>
</html>
`);
});
// POST /authorize - Complete authorization
app.post('/authorize', async (c) => {
const body = await c.req.parseBody();
const oauthReq = JSON.parse(body.oauthRequest as string);
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
request: oauthReq,
userId: 'user-' + Date.now(),
props: {
authenticated: true,
},
});
return Response.redirect(redirectTo);
});
export const oauthHandler = app;
```
---
## Step 6: Create a Widget Tool
Already done in Step 4. The key parts:
1. **Return `structuredContent`** - JSON data for the widget
2. **Include `_meta`** with `openai/outputTemplate` pointing to resource URI
3. **Return `content`** with text fallback for non-widget contexts
---
## Step 7: Register UI Resources
```typescript
// src/resources/ui.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const SKYBRIDGE_MIME = 'text/html+skybridge';
const ASSETS_BASE = 'https://my-widget-mcp.YOUR_SUBDOMAIN.workers.dev';
export function setupUIResources(server: McpServer): void {
server.resource(
'ui-list',
'ui://mymcp/list',
{
title: 'List Widget',
description: 'Interactive list widget',
mimeType: SKYBRIDGE_MIME,
},
async (resourceUri) => {
const html = `<div id="widget-root"></div>
<link rel="stylesheet" href="${ASSETS_BASE}/widgets/list.css">
<script src="${ASSETS_BASE}/widgets/list.js"></script>`;
return {
contents: [
{
uri: resourceUri.href,
mimeType: SKYBRIDGE_MIME,
text: html,
_meta: {
'openai/widgetPrefersBorder': true,
'openai/widgetDomain': 'https://chatgpt.com',
},
},
],
};
}
);
}
```
---
## Step 8: Add Widget Assets
```typescript
// src/widgets/assets.ts
export const WIDGET_LIST_JS = `
(function() {
'use strict';
var root = document.getElementById('widget-root');
if (!root) {
console.error('[Widget] Root element not found');
return;
}
var toolOutput = window.openai ? window.openai.toolOutput : null;
console.log('[Widget] toolOutput:', toolOutput);
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data available</div>';
return;
}
var data = toolOutput.structuredContent || toolOutput;
var title = data.title || 'Items';
var items = data.items || [];
var html = '<div class="header">' + escapeHtml(title) + '</div>';
if (items.length === 0) {
html += '<div class="empty">No items</div>';
} else {
html += '<div class="items">';
for (var i = 0; i < items.length; i++) {
var item = items[i];
html += '<div class="item">';
html += '<div class="icon">' + (item.icon || '📄') + '</div>';
html += '<div class="content">';
html += '<div class="title">' + escapeHtml(item.title || '') + '</div>';
if (item.subtitle) {
html += '<div class="subtitle">' + escapeHtml(item.subtitle) + '</div>';
}
html += '</div>';
html += '</div>';
}
html += '</div>';
}
root.innerHTML = html;
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
})();
`.trim();
export const WIDGET_LIST_CSS = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
color: #1f2937;
background: #fff;
padding: 16px;
}
.header { font-size: 18px; font-weight: 600; margin-bottom: 16px; }
.items { max-height: 400px; overflow-y: auto; }
.item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #e5e7eb;
}
.item:hover { background: #f9fafb; }
.item:last-child { border-bottom: none; }
.icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.content { flex: 1; min-width: 0; }
.title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtitle { font-size: 13px; color: #6b7280; margin-top: 2px; }
.empty { padding: 32px; text-align: center; color: #9ca3af; }
`.trim();
```
---
## Step 9: Wire Up the Main Worker
```typescript
// src/index.ts
import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
import { MyWidgetMCP } from './mcp-agent';
import { oauthHandler } from './oauth-handler';
import { WIDGET_LIST_JS, WIDGET_LIST_CSS } from './widgets/assets';
// Widget assets map
const widgetAssets: Record<string, { content: string; type: string }> = {
'/widgets/list.js': { content: WIDGET_LIST_JS, type: 'application/javascript' },
'/widgets/list.css': { content: WIDGET_LIST_CSS, type: 'text/css' },
};
// OAuth provider configuration
const oauthProvider = new OAuthProvider({
kvNamespace: 'OAUTH_KV',
apiRoute: '/sse',
apiHandler: MyWidgetMCP.mount('/sse'),
defaultHandler: oauthHandler,
authorizeEndpoint: '/authorize',
tokenEndpoint: '/token',
clientRegistrationEndpoint: '/register',
forceHTTPS: false,
lookupClient: async (clientId) => ({
id: clientId || 'default-client',
secret: 'client-secret',
allowed_redirects: ['*'],
name: 'MCP Client',
}),
});
// Main worker export
const mainWorker = {
async fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// Health check
if (url.pathname === '/' || url.pathname === '/health') {
return new Response('OK', { status: 200 });
}
// Static widget assets with CORS
if (url.pathname.startsWith('/widgets/')) {
// Handle CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
const asset = widgetAssets[url.pathname];
if (asset) {
return new Response(asset.content, {
headers: {
'Content-Type': asset.type,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=31536000',
},
});
}
return new Response('Not found', { status: 404 });
}
// Bypass OAuth for /sse (direct MCP access)
if (url.pathname === '/sse' || url.pathname.startsWith('/sse/')) {
return MyWidgetMCP.mount('/sse').fetch(request, env, ctx);
}
// All other routes through OAuth
return oauthProvider.fetch(request, env, ctx);
},
};
export default mainWorker;
export { MyWidgetMCP };
```
---
## Step 10: Build and Deploy
```bash
# Add build script to package.json
# "scripts": { "build": "tsc", "deploy": "wrangler deploy" }
# Build
npm run build
# Deploy
npm run deploy
```
You should see output like:
```
Deployed my-widget-mcp to https://my-widget-mcp.YOUR_SUBDOMAIN.workers.dev
```
### Verify deployment
```bash
# Check health
curl https://my-widget-mcp.YOUR_SUBDOMAIN.workers.dev/
# Check widget assets
curl https://my-widget-mcp.YOUR_SUBDOMAIN.workers.dev/widgets/list.js
curl https://my-widget-mcp.YOUR_SUBDOMAIN.workers.dev/widgets/list.css
```
---
## Step 11: Test in ChatGPT
1. **Add MCP to ChatGPT**:
- Go to ChatGPT settings → GPTs → Create
- Add MCP endpoint: `https://my-widget-mcp.YOUR_SUBDOMAIN.workers.dev/sse`
2. **Authorize**:
- ChatGPT will redirect to your `/authorize` endpoint
- Click "Authorize" to complete OAuth flow
3. **Test the widget**:
```
Show me the items widget
```
or
```
Use items_widget to display items
```
4. **See your widget render inline!**
---
## Project Structure
After completing all steps:
```
my-widget-mcp/
├── src/
│ ├── index.ts ← Main worker entry
│ ├── mcp-agent.ts ← McpAgent with tools
│ ├── oauth-handler.ts ← OAuth approval UI
│ ├── resources/
│ │ └── ui.ts ← UI resources (text/html+skybridge)
│ └── widgets/
│ └── assets.ts ← Widget JS/CSS as strings
├── wrangler.jsonc ← Cloudflare config
├── tsconfig.json ← TypeScript config
└── package.json
```
---
## Next Steps
- Add more widget tools (calendar, stats, queue)
- Implement real data fetching from your backend
- Add slug parameters for multi-tenant support
- See [widgets.md](widgets.md) for more widget patterns
- See [examples.md](examples.md) for complete code examples
Widget shows "No data available" on first load, but works after page refresh.
User: "Show my tables"
ChatGPT: [Renders widget]
Widget: "No data available" ← WRONG!
User: [Refreshes page]
Widget: [Shows tables correctly] ← Works now, but too late
Timing issue: Widget JS executes BEFORE ChatGPT injects window.openai.toolOutput.
javascript// ❌ BROKEN - Executes immediately, toolOutput not ready yet
(function() {
var toolOutput = window.openai ? window.openai.toolOutput : null;
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data available</div>';
return; // Exits before data arrives!
}
// Never reaches here on first load
})();
ChatGPT injects data asynchronously and fires an event when ready:
openai:set_globalstoolOutput, widgetState, theme, etc. are updatedListen for openai:set_globals event and re-render when data arrives.
1. Initial render → Check if data exists → Show loading/placeholder
2. Listen for openai:set_globals event
3. When event fires → Check if our data key changed → Re-render
4. Clean up listener when widget unmounts (if applicable)
javascript(function() {
'use strict';
var root = document.getElementById('widget-root');
if (!root) return;
// Track current data to detect changes
var currentData = null;
// Render function - called on init AND when data changes
function render() {
var toolOutput = window.openai?.toolOutput;
// No data yet? Show loading state (not error!)
if (!toolOutput) {
root.innerHTML = '<div class="loading">Loading...</div>';
return;
}
// Data arrived - render it
var data = toolOutput.structuredContent || toolOutput;
currentData = data;
// Your actual rendering logic
root.innerHTML = renderContent(data);
}
// Listen for data injection from ChatGPT
function handleSetGlobals(event) {
var globals = event.detail?.globals;
if (!globals) return;
// Only re-render if toolOutput changed
if (globals.toolOutput !== undefined) {
render();
}
}
// Initial render
render();
// Subscribe to data changes
window.addEventListener('openai:set_globals', handleSetGlobals, { passive: true });
// Cleanup on unmount (for frameworks that support it)
// window.addEventListener('unload', function() {
// window.removeEventListener('openai:set_globals', handleSetGlobals);
// });
function renderContent(data) {
// Your rendering logic here
return '<div class="content">' + JSON.stringify(data) + '</div>';
}
})();
javascript(function() {
'use strict';
var root = document.getElementById('snappy-tables-root');
if (!root) return;
var state = {
loading: true,
data: null,
error: null
};
function render() {
if (state.loading) {
root.innerHTML = renderLoading();
return;
}
if (state.error) {
root.innerHTML = renderError(state.error);
return;
}
if (!state.data || !state.data.tables || state.data.tables.length === 0) {
root.innerHTML = renderEmpty();
return;
}
root.innerHTML = renderTables(state.data);
}
function renderLoading() {
return '<div class="loading">' +
'<div class="spinner"></div>' +
'<span>Loading tables...</span>' +
'</div>';
}
function renderEmpty() {
return '<div class="empty">' +
'<div class="empty-icon">📭</div>' +
'<div class="empty-title">No tables found</div>' +
'<div class="empty-desc">This workspace doesn\'t have any tables yet.</div>' +
'</div>';
}
function renderError(message) {
return '<div class="error">' +
'<div class="error-icon">⚠️</div>' +
'<div class="error-message">' + escapeHtml(message) + '</div>' +
'</div>';
}
function renderTables(data) {
// Your table rendering logic
return '<div class="tables">...</div>';
}
function handleSetGlobals(event) {
var globals = event.detail?.globals;
if (!globals || globals.toolOutput === undefined) return;
var toolOutput = window.openai?.toolOutput;
if (toolOutput) {
state.loading = false;
state.data = toolOutput.structuredContent || toolOutput;
state.error = null;
}
render();
}
// Initial check - data might already be there
var initialData = window.openai?.toolOutput;
if (initialData) {
state.loading = false;
state.data = initialData.structuredContent || initialData;
}
// Subscribe to future updates
window.addEventListener('openai:set_globals', handleSetGlobals, { passive: true });
// Initial render
render();
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
})();
OpenAI's official examples use useSyncExternalStore for reactive data:
typescriptimport { useSyncExternalStore } from "react";
type OpenAiGlobals = {
toolOutput: unknown;
toolInput: unknown;
widgetState: unknown;
theme: "light" | "dark";
displayMode: "inline" | "fullscreen" | "pip";
maxHeight: number;
};
export function useOpenAiGlobal<K extends keyof OpenAiGlobals>(
key: K
): OpenAiGlobals[K] | null {
return useSyncExternalStore(
(onChange) => {
if (typeof window === "undefined") return () => {};
const handleSetGlobal = (event: CustomEvent<{ globals: Partial<OpenAiGlobals> }>) => {
if (event.detail.globals[key] !== undefined) {
onChange();
}
};
window.addEventListener("openai:set_globals", handleSetGlobal as EventListener, {
passive: true,
});
return () => {
window.removeEventListener("openai:set_globals", handleSetGlobal as EventListener);
};
},
() => (window as any).openai?.[key] ?? null,
() => (window as any).openai?.[key] ?? null
);
}
typescriptexport function useWidgetProps<T>(defaultState?: T): T | null {
const props = useOpenAiGlobal("toolOutput") as T | null;
return props ?? defaultState ?? null;
}
tsxfunction TablesWidget() {
const data = useWidgetProps<{ tables: Table[]; title: string }>();
if (!data) {
return <LoadingSpinner />;
}
if (data.tables.length === 0) {
return <EmptyState message="No tables in this workspace" />;
}
return (
<div className="tables-widget">
<h1>{data.title}</h1>
{data.tables.map(table => <TableCard key={table.id} table={table} />)}
</div>
);
}
javascript// ❌ BAD - Treats missing data as error
if (!toolOutput) {
root.innerHTML = '<div class="error">No data available</div>';
}
// ✅ GOOD - Shows appropriate loading state
if (!toolOutput) {
root.innerHTML = '<div class="loading">Loading...</div>';
}
// ✅ BETTER - Distinguish between "loading" and "truly empty"
if (state.loading) {
root.innerHTML = '<div class="loading">Loading tables...</div>';
} else if (tables.length === 0) {
root.innerHTML = '<div class="empty">No tables found in this workspace</div>';
}
javascriptfunction renderEmpty(context) {
return '<div class="empty-state">' +
'<div class="empty-icon">' + (context.icon || '📭') + '</div>' +
'<div class="empty-title">' + escapeHtml(context.title) + '</div>' +
'<div class="empty-desc">' + escapeHtml(context.description) + '</div>' +
(context.action ?
'<button class="empty-action">' + escapeHtml(context.action) + '</button>' :
'') +
'</div>';
}
// Usage
renderEmpty({
icon: '🗃️',
title: 'No tables yet',
description: 'Create your first table to get started.',
action: 'Create Table' // Optional CTA
});
css.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
gap: 12px;
color: #6b7280;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 48px 24px;
gap: 8px;
}
.empty-icon { font-size: 48px; margin-bottom: 8px; }
.empty-title { font-size: 16px; font-weight: 600; color: #1f2937; }
.empty-desc { font-size: 14px; color: #6b7280; max-width: 280px; }
javascript(function() {
'use strict';
var root = document.getElementById('snappy-tables-root');
if (!root) return;
// State
var state = {
phase: 'loading', // 'loading' | 'ready' | 'empty' | 'error'
data: null,
expanded: {} // For client-side expand/collapse
};
// Initial data check
var initialOutput = window.openai?.toolOutput;
if (initialOutput) {
state.phase = 'ready';
state.data = initialOutput.structuredContent || initialOutput;
if (!state.data.tables || state.data.tables.length === 0) {
state.phase = 'empty';
}
}
// Subscribe to data updates
window.addEventListener('openai:set_globals', function(event) {
var globals = event.detail?.globals;
if (!globals || globals.toolOutput === undefined) return;
var output = window.openai?.toolOutput;
if (output) {
state.data = output.structuredContent || output;
state.phase = (state.data.tables && state.data.tables.length > 0) ? 'ready' : 'empty';
} else {
state.phase = 'empty';
state.data = null;
}
render();
}, { passive: true });
// Render
function render() {
switch (state.phase) {
case 'loading':
root.innerHTML = renderLoading();
break;
case 'empty':
root.innerHTML = renderEmpty();
break;
case 'error':
root.innerHTML = renderError();
break;
case 'ready':
root.innerHTML = renderContent();
attachHandlers();
break;
}
}
function renderLoading() {
return '<div class="st-loading">' +
'<div class="st-spinner"></div>' +
'<span>Loading tables...</span>' +
'</div>';
}
function renderEmpty() {
return '<div class="st-empty">' +
'<div class="st-empty-icon">🗃️</div>' +
'<div class="st-empty-title">No tables found</div>' +
'<div class="st-empty-desc">This workspace is empty.</div>' +
'</div>';
}
function renderError() {
return '<div class="st-error">' +
'<div class="st-error-icon">⚠️</div>' +
'<div class="st-error-message">Failed to load tables</div>' +
'</div>';
}
function renderContent() {
var data = state.data;
var tables = data.tables || [];
var html = '<div class="st-widget">';
html += '<div class="st-header">';
html += '<span class="st-title">' + esc(data.title || 'Tables') + '</span>';
html += '<span class="st-count">' + tables.length + ' tables</span>';
html += '</div>';
html += '<div class="st-list">';
for (var i = 0; i < tables.length; i++) {
html += renderTableRow(tables[i]);
}
html += '</div>';
html += '</div>';
return html;
}
function renderTableRow(table) {
var isExpanded = state.expanded[table.id];
var html = '<div class="st-row' + (isExpanded ? ' expanded' : '') + '" data-id="' + table.id + '">';
html += '<div class="st-row-main">';
html += '<span class="st-expand">' + (isExpanded ? '▾' : '▸') + '</span>';
html += '<span class="st-name">' + esc(table.name) + '</span>';
html += '<span class="st-records">' + formatNum(table.record_count) + ' records</span>';
html += '</div>';
// Pre-loaded schema (client-side expand)
html += '<div class="st-detail-wrapper"><div class="st-detail">';
if (table.schema && table.schema.length > 0) {
for (var j = 0; j < table.schema.length; j++) {
var field = table.schema[j];
html += '<div class="st-field">' + esc(field.name) + ': ' + esc(field.type) + '</div>';
}
} else {
html += '<div class="st-no-schema">Schema not pre-loaded</div>';
}
html += '</div></div>';
html += '</div>';
return html;
}
function attachHandlers() {
root.querySelectorAll('.st-row').forEach(function(row) {
row.addEventListener('click', function() {
var id = this.getAttribute('data-id');
state.expanded[id] = !state.expanded[id];
render();
});
});
}
function esc(t) { return t == null ? '' : String(t).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
function formatNum(n) { return n >= 1000 ? (n/1000).toFixed(1)+'K' : String(n || 0); }
// Initial render
render();
})();
openai:set_globals firesjavascript// Add to your widget for debugging
console.log('[Widget] Initial check:', {
hasOpenai: !!window.openai,
hasToolOutput: !!window.openai?.toolOutput,
toolOutput: window.openai?.toolOutput
});
window.addEventListener('openai:set_globals', function(e) {
console.log('[Widget] set_globals event:', e.detail);
});
For production widgets, create a shared utilities module that handles all OpenAI Apps SDK features:
javascript// snappy-utils.js - Loaded BEFORE widget JS via <script> tag
var SnappyUtils = (function() {
'use strict';
// === THEME SUPPORT ===
function getTheme() {
if (window.openai && window.openai.theme) return window.openai.theme;
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
return 'light';
}
function applyTheme() {
var theme = getTheme();
document.documentElement.setAttribute('data-theme', theme);
if (theme === 'dark') document.body.classList.add('dark-mode');
else document.body.classList.remove('dark-mode');
}
// === MAX HEIGHT SUPPORT ===
function getMaxHeight(defaultHeight) {
if (window.openai && typeof window.openai.maxHeight === 'number') {
return window.openai.maxHeight;
}
return defaultHeight || 500;
}
function applyMaxHeight(element, defaultHeight) {
if (!element) return;
var height = getMaxHeight(defaultHeight);
element.style.maxHeight = height + 'px';
element.style.overflowY = 'auto';
}
function onMaxHeightChange(callback) {
window.addEventListener('openai:set_globals', function(event) {
var globals = event.detail && event.detail.globals;
if (globals && globals.maxHeight !== undefined) {
callback(globals.maxHeight);
}
}, { passive: true });
}
// === DISPLAY MODE ===
function getDisplayMode() {
return (window.openai && window.openai.displayMode) || 'inline';
}
// === TOOL INTERACTIONS ===
function callTool(toolName, args) {
if (window.openai && window.openai.callTool) {
window.openai.callTool(toolName, args || {});
} else {
console.warn('[SnappyUtils] callTool not available');
}
}
function sendFollowUp(prompt) {
if (window.openai && window.openai.sendFollowUpMessage) {
window.openai.sendFollowUpMessage({ prompt: prompt });
} else {
console.warn('[SnappyUtils] sendFollowUpMessage not available');
}
}
// === COMMON HELPERS ===
function escapeHtml(text) {
if (text == null) return '';
return String(text).replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
function formatNumber(num) {
if (num == null) return '0';
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return String(num);
}
function formatDate(dateStr) {
if (!dateStr) return '';
try {
var d = new Date(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch (e) { return dateStr; }
}
// Public API
return {
getTheme: getTheme,
applyTheme: applyTheme,
getMaxHeight: getMaxHeight,
applyMaxHeight: applyMaxHeight,
onMaxHeightChange: onMaxHeightChange,
getDisplayMode: getDisplayMode,
callTool: callTool,
sendFollowUp: sendFollowUp,
escapeHtml: escapeHtml,
formatNumber: formatNumber,
formatDate: formatDate
};
})();
javascript(function() {
'use strict';
var root = document.getElementById('my-widget-root');
if (!root) return;
// === USE SHARED UTILITIES ===
var esc = SnappyUtils.escapeHtml; // Alias for convenience
// Listen for max height changes
SnappyUtils.onMaxHeightChange(function(height) {
SnappyUtils.applyMaxHeight(root, height);
});
// Apply theme on load
SnappyUtils.applyTheme();
// Your widget logic...
function render() {
var toolOutput = window.openai?.toolOutput;
if (!toolOutput) {
root.innerHTML = '<div class="loading">Loading...</div>';
return;
}
// Render using esc() instead of defining escapeHtml locally
root.innerHTML = '<div>' + esc(data.title) + '</div>';
}
// Initial render + max height
SnappyUtils.applyMaxHeight(root, 500);
render();
// React to data changes
window.addEventListener('openai:set_globals', function(e) {
if (e.detail?.globals?.toolOutput !== undefined) render();
}, { passive: true });
})();
css/* snappy-theme.css - CSS Variables for Light/Dark Mode */
:root {
--snappy-bg: #f9fafb;
--snappy-bg-card: #ffffff;
--snappy-border: #e5e7eb;
--snappy-text: #1f2937;
--snappy-text-secondary: #6b7280;
--snappy-primary: #3b82f6;
--snappy-success-bg: #d1fae5;
--snappy-success: #065f46;
}
[data-theme="dark"], .dark-mode {
--snappy-bg: #111827;
--snappy-bg-card: #1f2937;
--snappy-border: #374151;
--snappy-text: #f9fafb;
--snappy-text-secondary: #9ca3af;
--snappy-primary: #60a5fa;
--snappy-success-bg: #064e3b;
--snappy-success: #6ee7b7;
}
/* Use variables in your styles */
body {
background: var(--snappy-bg);
color: var(--snappy-text);
}
.card {
background: var(--snappy-bg-card);
border: 1px solid var(--snappy-border);
}
typescript// CRITICAL: Load order matters!
function getWidgetHtml(widgetId: string, rootId: string): string {
const v = `?v${VERSION}`; // Cache buster
const base = 'https://mymcp.workers.dev';
return [
`<div id="${rootId}"></div>`,
// 1. Theme CSS (CSS variables) - FIRST
`<link rel="stylesheet" href="${base}/widgets/theme.css${v}">`,
// 2. Shared CSS (common styles)
`<link rel="stylesheet" href="${base}/widgets/shared.css${v}">`,
// 3. Widget-specific CSS
`<link rel="stylesheet" href="${base}/widgets/${widgetId}.css${v}">`,
// 4. Shared Utils JS - MUST load before widget JS
`<script src="${base}/widgets/utils.js${v}"></script>`,
// 5. Widget JS (uses SnappyUtils)
`<script src="${base}/widgets/${widgetId}.js${v}"></script>`,
].join('');
}
# Reactive Widgets - Fixing Empty Widget Issues
## Table of Contents
- [The Problem](#the-problem)
- [Root Cause](#root-cause)
- [The Solution: Event-Driven Hydration](#the-solution-event-driven-hydration)
- [Vanilla JS Pattern (No Framework)](#vanilla-js-pattern-no-framework)
- [React Pattern (With Hooks)](#react-pattern-with-hooks)
- [Graceful Empty States](#graceful-empty-states)
- [Complete Working Example](#complete-working-example)
- [Testing Checklist](#testing-checklist)
---
## The Problem
Widget shows "No data available" on first load, but works after page refresh.
```
User: "Show my tables"
ChatGPT: [Renders widget]
Widget: "No data available" ← WRONG!
User: [Refreshes page]
Widget: [Shows tables correctly] ← Works now, but too late
```
---
## Root Cause
**Timing issue**: Widget JS executes BEFORE ChatGPT injects `window.openai.toolOutput`.
```javascript
// ❌ BROKEN - Executes immediately, toolOutput not ready yet
(function() {
var toolOutput = window.openai ? window.openai.toolOutput : null;
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data available</div>';
return; // Exits before data arrives!
}
// Never reaches here on first load
})();
```
ChatGPT injects data **asynchronously** and fires an event when ready:
- Event: `openai:set_globals`
- Fires when: `toolOutput`, `widgetState`, `theme`, etc. are updated
---
## The Solution: Event-Driven Hydration
Listen for `openai:set_globals` event and re-render when data arrives.
### The Pattern
```
1. Initial render → Check if data exists → Show loading/placeholder
2. Listen for openai:set_globals event
3. When event fires → Check if our data key changed → Re-render
4. Clean up listener when widget unmounts (if applicable)
```
---
## Vanilla JS Pattern (No Framework)
### Minimal Implementation
```javascript
(function() {
'use strict';
var root = document.getElementById('widget-root');
if (!root) return;
// Track current data to detect changes
var currentData = null;
// Render function - called on init AND when data changes
function render() {
var toolOutput = window.openai?.toolOutput;
// No data yet? Show loading state (not error!)
if (!toolOutput) {
root.innerHTML = '<div class="loading">Loading...</div>';
return;
}
// Data arrived - render it
var data = toolOutput.structuredContent || toolOutput;
currentData = data;
// Your actual rendering logic
root.innerHTML = renderContent(data);
}
// Listen for data injection from ChatGPT
function handleSetGlobals(event) {
var globals = event.detail?.globals;
if (!globals) return;
// Only re-render if toolOutput changed
if (globals.toolOutput !== undefined) {
render();
}
}
// Initial render
render();
// Subscribe to data changes
window.addEventListener('openai:set_globals', handleSetGlobals, { passive: true });
// Cleanup on unmount (for frameworks that support it)
// window.addEventListener('unload', function() {
// window.removeEventListener('openai:set_globals', handleSetGlobals);
// });
function renderContent(data) {
// Your rendering logic here
return '<div class="content">' + JSON.stringify(data) + '</div>';
}
})();
```
### Full Example with Loading States
```javascript
(function() {
'use strict';
var root = document.getElementById('snappy-tables-root');
if (!root) return;
var state = {
loading: true,
data: null,
error: null
};
function render() {
if (state.loading) {
root.innerHTML = renderLoading();
return;
}
if (state.error) {
root.innerHTML = renderError(state.error);
return;
}
if (!state.data || !state.data.tables || state.data.tables.length === 0) {
root.innerHTML = renderEmpty();
return;
}
root.innerHTML = renderTables(state.data);
}
function renderLoading() {
return '<div class="loading">' +
'<div class="spinner"></div>' +
'<span>Loading tables...</span>' +
'</div>';
}
function renderEmpty() {
return '<div class="empty">' +
'<div class="empty-icon">📭</div>' +
'<div class="empty-title">No tables found</div>' +
'<div class="empty-desc">This workspace doesn\'t have any tables yet.</div>' +
'</div>';
}
function renderError(message) {
return '<div class="error">' +
'<div class="error-icon">⚠️</div>' +
'<div class="error-message">' + escapeHtml(message) + '</div>' +
'</div>';
}
function renderTables(data) {
// Your table rendering logic
return '<div class="tables">...</div>';
}
function handleSetGlobals(event) {
var globals = event.detail?.globals;
if (!globals || globals.toolOutput === undefined) return;
var toolOutput = window.openai?.toolOutput;
if (toolOutput) {
state.loading = false;
state.data = toolOutput.structuredContent || toolOutput;
state.error = null;
}
render();
}
// Initial check - data might already be there
var initialData = window.openai?.toolOutput;
if (initialData) {
state.loading = false;
state.data = initialData.structuredContent || initialData;
}
// Subscribe to future updates
window.addEventListener('openai:set_globals', handleSetGlobals, { passive: true });
// Initial render
render();
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
})();
```
---
## React Pattern (With Hooks)
OpenAI's official examples use `useSyncExternalStore` for reactive data:
### useOpenAiGlobal Hook
```typescript
import { useSyncExternalStore } from "react";
type OpenAiGlobals = {
toolOutput: unknown;
toolInput: unknown;
widgetState: unknown;
theme: "light" | "dark";
displayMode: "inline" | "fullscreen" | "pip";
maxHeight: number;
};
export function useOpenAiGlobal<K extends keyof OpenAiGlobals>(
key: K
): OpenAiGlobals[K] | null {
return useSyncExternalStore(
(onChange) => {
if (typeof window === "undefined") return () => {};
const handleSetGlobal = (event: CustomEvent<{ globals: Partial<OpenAiGlobals> }>) => {
if (event.detail.globals[key] !== undefined) {
onChange();
}
};
window.addEventListener("openai:set_globals", handleSetGlobal as EventListener, {
passive: true,
});
return () => {
window.removeEventListener("openai:set_globals", handleSetGlobal as EventListener);
};
},
() => (window as any).openai?.[key] ?? null,
() => (window as any).openai?.[key] ?? null
);
}
```
### useWidgetProps Hook
```typescript
export function useWidgetProps<T>(defaultState?: T): T | null {
const props = useOpenAiGlobal("toolOutput") as T | null;
return props ?? defaultState ?? null;
}
```
### Usage in Component
```tsx
function TablesWidget() {
const data = useWidgetProps<{ tables: Table[]; title: string }>();
if (!data) {
return <LoadingSpinner />;
}
if (data.tables.length === 0) {
return <EmptyState message="No tables in this workspace" />;
}
return (
<div className="tables-widget">
<h1>{data.title}</h1>
{data.tables.map(table => <TableCard key={table.id} table={table} />)}
</div>
);
}
```
---
## Graceful Empty States
### Don't Show "Error" for Empty Data
```javascript
// ❌ BAD - Treats missing data as error
if (!toolOutput) {
root.innerHTML = '<div class="error">No data available</div>';
}
// ✅ GOOD - Shows appropriate loading state
if (!toolOutput) {
root.innerHTML = '<div class="loading">Loading...</div>';
}
// ✅ BETTER - Distinguish between "loading" and "truly empty"
if (state.loading) {
root.innerHTML = '<div class="loading">Loading tables...</div>';
} else if (tables.length === 0) {
root.innerHTML = '<div class="empty">No tables found in this workspace</div>';
}
```
### Empty State Design
```javascript
function renderEmpty(context) {
return '<div class="empty-state">' +
'<div class="empty-icon">' + (context.icon || '📭') + '</div>' +
'<div class="empty-title">' + escapeHtml(context.title) + '</div>' +
'<div class="empty-desc">' + escapeHtml(context.description) + '</div>' +
(context.action ?
'<button class="empty-action">' + escapeHtml(context.action) + '</button>' :
'') +
'</div>';
}
// Usage
renderEmpty({
icon: '🗃️',
title: 'No tables yet',
description: 'Create your first table to get started.',
action: 'Create Table' // Optional CTA
});
```
### Loading State CSS
```css
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
gap: 12px;
color: #6b7280;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 48px 24px;
gap: 8px;
}
.empty-icon { font-size: 48px; margin-bottom: 8px; }
.empty-title { font-size: 16px; font-weight: 600; color: #1f2937; }
.empty-desc { font-size: 14px; color: #6b7280; max-width: 280px; }
```
---
## Complete Working Example
### Widget JS with Reactive Data Hydration
```javascript
(function() {
'use strict';
var root = document.getElementById('snappy-tables-root');
if (!root) return;
// State
var state = {
phase: 'loading', // 'loading' | 'ready' | 'empty' | 'error'
data: null,
expanded: {} // For client-side expand/collapse
};
// Initial data check
var initialOutput = window.openai?.toolOutput;
if (initialOutput) {
state.phase = 'ready';
state.data = initialOutput.structuredContent || initialOutput;
if (!state.data.tables || state.data.tables.length === 0) {
state.phase = 'empty';
}
}
// Subscribe to data updates
window.addEventListener('openai:set_globals', function(event) {
var globals = event.detail?.globals;
if (!globals || globals.toolOutput === undefined) return;
var output = window.openai?.toolOutput;
if (output) {
state.data = output.structuredContent || output;
state.phase = (state.data.tables && state.data.tables.length > 0) ? 'ready' : 'empty';
} else {
state.phase = 'empty';
state.data = null;
}
render();
}, { passive: true });
// Render
function render() {
switch (state.phase) {
case 'loading':
root.innerHTML = renderLoading();
break;
case 'empty':
root.innerHTML = renderEmpty();
break;
case 'error':
root.innerHTML = renderError();
break;
case 'ready':
root.innerHTML = renderContent();
attachHandlers();
break;
}
}
function renderLoading() {
return '<div class="st-loading">' +
'<div class="st-spinner"></div>' +
'<span>Loading tables...</span>' +
'</div>';
}
function renderEmpty() {
return '<div class="st-empty">' +
'<div class="st-empty-icon">🗃️</div>' +
'<div class="st-empty-title">No tables found</div>' +
'<div class="st-empty-desc">This workspace is empty.</div>' +
'</div>';
}
function renderError() {
return '<div class="st-error">' +
'<div class="st-error-icon">⚠️</div>' +
'<div class="st-error-message">Failed to load tables</div>' +
'</div>';
}
function renderContent() {
var data = state.data;
var tables = data.tables || [];
var html = '<div class="st-widget">';
html += '<div class="st-header">';
html += '<span class="st-title">' + esc(data.title || 'Tables') + '</span>';
html += '<span class="st-count">' + tables.length + ' tables</span>';
html += '</div>';
html += '<div class="st-list">';
for (var i = 0; i < tables.length; i++) {
html += renderTableRow(tables[i]);
}
html += '</div>';
html += '</div>';
return html;
}
function renderTableRow(table) {
var isExpanded = state.expanded[table.id];
var html = '<div class="st-row' + (isExpanded ? ' expanded' : '') + '" data-id="' + table.id + '">';
html += '<div class="st-row-main">';
html += '<span class="st-expand">' + (isExpanded ? '▾' : '▸') + '</span>';
html += '<span class="st-name">' + esc(table.name) + '</span>';
html += '<span class="st-records">' + formatNum(table.record_count) + ' records</span>';
html += '</div>';
// Pre-loaded schema (client-side expand)
html += '<div class="st-detail-wrapper"><div class="st-detail">';
if (table.schema && table.schema.length > 0) {
for (var j = 0; j < table.schema.length; j++) {
var field = table.schema[j];
html += '<div class="st-field">' + esc(field.name) + ': ' + esc(field.type) + '</div>';
}
} else {
html += '<div class="st-no-schema">Schema not pre-loaded</div>';
}
html += '</div></div>';
html += '</div>';
return html;
}
function attachHandlers() {
root.querySelectorAll('.st-row').forEach(function(row) {
row.addEventListener('click', function() {
var id = this.getAttribute('data-id');
state.expanded[id] = !state.expanded[id];
render();
});
});
}
function esc(t) { return t == null ? '' : String(t).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
function formatNum(n) { return n >= 1000 ? (n/1000).toFixed(1)+'K' : String(n || 0); }
// Initial render
render();
})();
```
---
## Testing Checklist
### Before Deploy
- [ ] Widget shows loading spinner on first render (not error)
- [ ] Widget updates when `openai:set_globals` fires
- [ ] Empty state shows when data has zero items
- [ ] Error state shows for actual errors only
- [ ] Client-side interactions work without tool calls
- [ ] CSS animations are smooth (no layout shift)
### Manual Test Steps
1. Clear browser cache
2. Open ChatGPT with your MCP
3. Invoke widget tool
4. **First render**: Should show "Loading..." NOT "No data"
5. **After hydration**: Should show actual content
6. **Page refresh**: Should work immediately (data cached)
### Debug Logging
```javascript
// Add to your widget for debugging
console.log('[Widget] Initial check:', {
hasOpenai: !!window.openai,
hasToolOutput: !!window.openai?.toolOutput,
toolOutput: window.openai?.toolOutput
});
window.addEventListener('openai:set_globals', function(e) {
console.log('[Widget] set_globals event:', e.detail);
});
```
---
## Advanced: Shared Utilities Pattern (SnappyUtils)
For production widgets, create a shared utilities module that handles all OpenAI Apps SDK features:
### The Shared Utils Pattern
```javascript
// snappy-utils.js - Loaded BEFORE widget JS via <script> tag
var SnappyUtils = (function() {
'use strict';
// === THEME SUPPORT ===
function getTheme() {
if (window.openai && window.openai.theme) return window.openai.theme;
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
return 'light';
}
function applyTheme() {
var theme = getTheme();
document.documentElement.setAttribute('data-theme', theme);
if (theme === 'dark') document.body.classList.add('dark-mode');
else document.body.classList.remove('dark-mode');
}
// === MAX HEIGHT SUPPORT ===
function getMaxHeight(defaultHeight) {
if (window.openai && typeof window.openai.maxHeight === 'number') {
return window.openai.maxHeight;
}
return defaultHeight || 500;
}
function applyMaxHeight(element, defaultHeight) {
if (!element) return;
var height = getMaxHeight(defaultHeight);
element.style.maxHeight = height + 'px';
element.style.overflowY = 'auto';
}
function onMaxHeightChange(callback) {
window.addEventListener('openai:set_globals', function(event) {
var globals = event.detail && event.detail.globals;
if (globals && globals.maxHeight !== undefined) {
callback(globals.maxHeight);
}
}, { passive: true });
}
// === DISPLAY MODE ===
function getDisplayMode() {
return (window.openai && window.openai.displayMode) || 'inline';
}
// === TOOL INTERACTIONS ===
function callTool(toolName, args) {
if (window.openai && window.openai.callTool) {
window.openai.callTool(toolName, args || {});
} else {
console.warn('[SnappyUtils] callTool not available');
}
}
function sendFollowUp(prompt) {
if (window.openai && window.openai.sendFollowUpMessage) {
window.openai.sendFollowUpMessage({ prompt: prompt });
} else {
console.warn('[SnappyUtils] sendFollowUpMessage not available');
}
}
// === COMMON HELPERS ===
function escapeHtml(text) {
if (text == null) return '';
return String(text).replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
function formatNumber(num) {
if (num == null) return '0';
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return String(num);
}
function formatDate(dateStr) {
if (!dateStr) return '';
try {
var d = new Date(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch (e) { return dateStr; }
}
// Public API
return {
getTheme: getTheme,
applyTheme: applyTheme,
getMaxHeight: getMaxHeight,
applyMaxHeight: applyMaxHeight,
onMaxHeightChange: onMaxHeightChange,
getDisplayMode: getDisplayMode,
callTool: callTool,
sendFollowUp: sendFollowUp,
escapeHtml: escapeHtml,
formatNumber: formatNumber,
formatDate: formatDate
};
})();
```
### Widget JS Using Shared Utils
```javascript
(function() {
'use strict';
var root = document.getElementById('my-widget-root');
if (!root) return;
// === USE SHARED UTILITIES ===
var esc = SnappyUtils.escapeHtml; // Alias for convenience
// Listen for max height changes
SnappyUtils.onMaxHeightChange(function(height) {
SnappyUtils.applyMaxHeight(root, height);
});
// Apply theme on load
SnappyUtils.applyTheme();
// Your widget logic...
function render() {
var toolOutput = window.openai?.toolOutput;
if (!toolOutput) {
root.innerHTML = '<div class="loading">Loading...</div>';
return;
}
// Render using esc() instead of defining escapeHtml locally
root.innerHTML = '<div>' + esc(data.title) + '</div>';
}
// Initial render + max height
SnappyUtils.applyMaxHeight(root, 500);
render();
// React to data changes
window.addEventListener('openai:set_globals', function(e) {
if (e.detail?.globals?.toolOutput !== undefined) render();
}, { passive: true });
})();
```
### CSS Variables for Theming
```css
/* snappy-theme.css - CSS Variables for Light/Dark Mode */
:root {
--snappy-bg: #f9fafb;
--snappy-bg-card: #ffffff;
--snappy-border: #e5e7eb;
--snappy-text: #1f2937;
--snappy-text-secondary: #6b7280;
--snappy-primary: #3b82f6;
--snappy-success-bg: #d1fae5;
--snappy-success: #065f46;
}
[data-theme="dark"], .dark-mode {
--snappy-bg: #111827;
--snappy-bg-card: #1f2937;
--snappy-border: #374151;
--snappy-text: #f9fafb;
--snappy-text-secondary: #9ca3af;
--snappy-primary: #60a5fa;
--snappy-success-bg: #064e3b;
--snappy-success: #6ee7b7;
}
/* Use variables in your styles */
body {
background: var(--snappy-bg);
color: var(--snappy-text);
}
.card {
background: var(--snappy-bg-card);
border: 1px solid var(--snappy-border);
}
```
### Resource HTML Load Order
```typescript
// CRITICAL: Load order matters!
function getWidgetHtml(widgetId: string, rootId: string): string {
const v = `?v${VERSION}`; // Cache buster
const base = 'https://mymcp.workers.dev';
return [
`<div id="${rootId}"></div>`,
// 1. Theme CSS (CSS variables) - FIRST
`<link rel="stylesheet" href="${base}/widgets/theme.css${v}">`,
// 2. Shared CSS (common styles)
`<link rel="stylesheet" href="${base}/widgets/shared.css${v}">`,
// 3. Widget-specific CSS
`<link rel="stylesheet" href="${base}/widgets/${widgetId}.css${v}">`,
// 4. Shared Utils JS - MUST load before widget JS
`<script src="${base}/widgets/utils.js${v}"></script>`,
// 5. Widget JS (uses SnappyUtils)
`<script src="${base}/widgets/${widgetId}.js${v}"></script>`,
].join('');
}
```
### Benefits of Shared Utils
1. **No code duplication** - escapeHtml, formatNumber, formatDate defined once
2. **Consistent theming** - All widgets use same CSS variables
3. **Automatic SDK feature support** - maxHeight, theme, callTool all handled
4. **Easy maintenance** - Update utils.js, all widgets get the fix
5. **Smaller widget files** - Each widget is just rendering logic
---
## Sources
- [OpenAI Apps SDK - State Management](https://developers.openai.com/apps-sdk/build/state-management/)
- [OpenAI Apps SDK Examples - use-openai-global.ts](https://github.com/openai/openai-apps-sdk-examples)
- [OpenAI Apps SDK - Build Your ChatGPT UI](https://developers.openai.com/apps-sdk/build/chatgpt-ui/)
---
## Next Steps
- See [ui-guidelines.md](ui-guidelines.md) for design principles
- See [domain-widgets.md](domain-widgets.md) for one-widget-per-domain pattern
- See [interactive-widgets.md](interactive-widgets.md) for callTool patterns
Apps extend what users can do without breaking the flow of conversation. They appear through:
Key Resource: Use the Apps SDK UI design system for Tailwind styling, CSS variable tokens, and accessible components.
Figma: Component Library
| Mode | Use When |
|---|---|
| Inline Card | Single action, small data, self-contained widget |
| Inline Carousel | 3-8 similar items with visuals (restaurants, playlists) |
| Fullscreen | Multi-step workflows, rich content, deep exploration |
| Picture-in-Picture | Ongoing sessions (games, videos, live collaboration) |
Lightweight, single-purpose widgets embedded directly in conversation.
❌ DON'T:
- More than 2 primary actions
- Deep navigation or multiple views
- Nested scrolling (cards should auto-fit)
- Duplicate ChatGPT features (no text input that replicates composer)
✅ DO:
- Limit to 2 CTAs max (1 primary, 1 secondary)
- Single-purpose, self-contained
- Auto-fit content height
- Use system composer for user input
Cards presented side-by-side for scanning multiple options.
Immersive experiences with ChatGPT composer overlay.
The composer is ALWAYS present in fullscreen.
Your UX must support conversational prompts that trigger tool calls.
Don't fight it - embrace it.
Persistent floating window for ongoing/live sessions.
Use system colors. Partner branding through accents only.
✅ DO:
- System colors for text, icons, dividers
- Brand accent on primary buttons
- Logo/icon for brand recognition
❌ DON'T:
- Custom backgrounds
- Override text colors
- Custom gradients breaking minimal look
System fonts only (SF Pro iOS, Roboto Android).
✅ DO:
- Inherit system font stack
- Use body and body-small sizes
- Bold/italic for content emphasis
❌ DON'T:
- Custom fonts (even in fullscreen!)
- Excessive font size variation
- Style structural UI differently
From openai-apps-sdk-examples:
| Component | Use Case |
|---|---|
| List | Dynamic collections with empty states |
| Map | Geo data with marker clustering |
| Album | Media grids with fullscreen |
| Carousel | Featured content with swipe |
| Shop | Product browsing with checkout |
window.openai.toolOutputcallTool returnssetWidgetState for caching| State Type | Location | Example |
|---|---|---|
| Component State | setWidgetState() |
Selected record, scroll position, staged form |
| Server State | Your backend | Authoritative data, merge after tool calls |
| Model Messages | sendFollowUpMessage() |
Human-readable updates to transcript |
User clicks in widget
↓
callTool() to execute action
↓
Update component state (setWidgetState)
↓
Optionally sendFollowUpMessage for transcript
↓
Re-render with new data
Widgets that fetch data on every click are inefficient:
Load all data upfront, reveal it client-side.
typescript// Tool: Pre-load schemas for first 20 tables
const tables = await fetchTables();
const schemasToLoad = tables.slice(0, 20);
// Parallel fetch schemas
const schemas = await Promise.all(
schemasToLoad.map(t => fetchSchema(t.id))
);
return {
structuredContent: {
tables,
schemas: Object.fromEntries(
schemasToLoad.map((t, i) => [t.id, schemas[i]])
),
},
_meta: getWidgetMeta('table-explorer'),
};
| Guideline | Value | Reason |
|---|---|---|
| Max height | 350px | Fits in conversation flow |
| Click behavior | Client-side expand | No round-trips |
| Data loading | Upfront for top N items | Instant reveals |
| Animation | CSS Grid 0fr→1fr | Smooth height transitions |
┌─────────────────────────────────────┐
│ 📊 Tables (47 tables) │
├─────────────────────────────────────┤
│ ▸ users 42,150 records │ ← Click to expand
│ ▾ transactions 1.2M records │ ← Expanded (schema visible)
│ ├─ id: integer (pk, autoincr) │
│ ├─ user_id: integer (fk → users) │
│ ├─ amount: decimal │
│ └─ created_at: timestamp │
│ ▸ products 2,340 records │
│ ▸ orders 156K records │
└─────────────────────────────────────┘
height: auto can't be animated with CSS transitions. Widgets need smooth expand/collapse.
css/* Container uses grid with row sizing */
.row-wrapper {
display: grid;
grid-template-rows: 0fr; /* Collapsed: 0 fractional units */
transition: grid-template-rows 250ms ease-out;
}
/* Expanded state */
.row.expanded .row-wrapper {
grid-template-rows: 1fr; /* Expanded: 1 fractional unit */
}
/* Inner content must have overflow: hidden */
.row-content {
overflow: hidden;
}
javascript// Widget JS: Toggle expand on click
row.addEventListener('click', function() {
this.classList.toggle('expanded');
});
css/* Widget CSS: Smooth expand/collapse */
.te-row { cursor: pointer; }
.te-row:hover { background: #f9fafb; }
/* The magic: grid-template-rows animates height */
.te-detail-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 250ms ease-out;
}
.te-row.expanded .te-detail-wrapper {
grid-template-rows: 1fr;
}
/* Content must have overflow: hidden */
.te-detail {
overflow: hidden;
padding: 0 16px;
}
/* Only show padding when expanded */
.te-row.expanded .te-detail {
padding: 12px 16px;
}
| Traditional Approach | Grid Approach |
|---|---|
height: 0 → height: auto |
grid-template-rows: 0fr → 1fr |
| Can't animate! | Animates smoothly! |
| Need JS height calculation | Pure CSS solution |
| Janky or no animation | Butter-smooth 250ms |
| Do | Don't |
|---|---|
| 1-2 CTAs max | Deep navigation |
| Auto-fit height | Nested scrolling |
| System colors | Custom backgrounds |
| Single purpose | Replicate composer |
| Do | Don't |
|---|---|
| Work WITH composer | Fight the overlay |
| Deepen engagement | Replicate native app |
| Handle chat input | Ignore conversation |
| Do | Don't |
|---|---|
| System fonts | Custom fonts |
| Brand accents | Override text colors |
| Consistent spacing | Cramped layouts |
| Alt text | Skip accessibility |
Key Takeaway: Design WITH ChatGPT, not against it. The composer is always there. Embrace conversation-first UX.
# UI Guidelines - OpenAI Apps SDK Design System
## Table of Contents
- [Overview](#overview)
- [Display Modes](#display-modes)
- [Inline Cards](#inline-cards)
- [Inline Carousel](#inline-carousel)
- [Fullscreen](#fullscreen)
- [Picture-in-Picture](#picture-in-picture)
- [Visual Design Guidelines](#visual-design-guidelines)
- [Design Components](#design-components)
- [State Contract](#state-contract)
- [Compact Widget Design](#compact-widget-design-information-dense)
- [CSS Grid Animation Technique](#css-grid-animation-technique)
---
## Overview
Apps extend what users can do without breaking the flow of conversation. They appear through:
- Lightweight **cards**
- **Carousels**
- **Fullscreen** views
- **Picture-in-picture** mode
**Key Resource**: Use the [Apps SDK UI](https://openai.github.io/apps-sdk-ui/) design system for Tailwind styling, CSS variable tokens, and accessible components.
**Figma**: [Component Library](https://www.figma.com/community/file/1560064615791108827/apps-in-chatgpt-components-templates)
---
## Display Modes
### Mode Selection Guide
| Mode | Use When |
|------|----------|
| **Inline Card** | Single action, small data, self-contained widget |
| **Inline Carousel** | 3-8 similar items with visuals (restaurants, playlists) |
| **Fullscreen** | Multi-step workflows, rich content, deep exploration |
| **Picture-in-Picture** | Ongoing sessions (games, videos, live collaboration) |
---
## Inline Cards
Lightweight, single-purpose widgets embedded directly in conversation.
### When to Use
- Single action or decision (confirm booking)
- Small structured data (map, order summary, status)
- Self-contained widget (audio player, scorecard)
### Layout Rules
- **Title**: Include if document-based or has parent element (songs in playlist)
- **Expand button**: Opens fullscreen for rich media/interactivity
- **Show more**: Disclose additional items in lists
- **Primary actions**: MAX 2 actions at bottom, perform conversation turn or tool call
### Interaction Rules
- **States persist**: Edits made are saved
- **Simple direct edits**: Inline editable text for quick changes
- **Dynamic height**: Expands up to mobile viewport height
### CRITICAL Rules (Don't Break These)
```
❌ DON'T:
- More than 2 primary actions
- Deep navigation or multiple views
- Nested scrolling (cards should auto-fit)
- Duplicate ChatGPT features (no text input that replicates composer)
✅ DO:
- Limit to 2 CTAs max (1 primary, 1 secondary)
- Single-purpose, self-contained
- Auto-fit content height
- Use system composer for user input
```
---
## Inline Carousel
Cards presented side-by-side for scanning multiple options.
### When to Use
- Small list of similar items (restaurants, events, playlists)
- Items need visual content + metadata beyond simple rows
### Layout
- **Image**: Required - always include visual
- **Title**: Required - explain the content
- **Metadata**: 2-3 lines MAX of relevant info
- **Badge**: Supporting context where appropriate
- **Actions**: Single CTA per item ("Book", "Play")
### Rules
- **3-8 items** per carousel for scannability
- Consistent visual hierarchy across cards
- Single, optional CTA per card
---
## Fullscreen
Immersive experiences with ChatGPT composer overlay.
### When to Use
- Rich tasks that can't fit in a card (explorable map, editing canvas, diagrams)
- Browsing detailed content (real estate, menus)
### Layout
- **System close**: Always present (user can exit)
- **Content area**: Your fullscreen view
- **Composer**: ChatGPT's native composer overlaid (always visible)
### Interaction
- **Chat sheet**: Maintains conversational context
- **Thinking**: Composer "shimmers" while streaming
- **Response**: Truncated snippet above composer, tap to expand
### CRITICAL: Design WITH the System Composer
```
The composer is ALWAYS present in fullscreen.
Your UX must support conversational prompts that trigger tool calls.
Don't fight it - embrace it.
```
---
## Picture-in-Picture (PiP)
Persistent floating window for ongoing/live sessions.
### When to Use
- Activities running parallel to conversation (games, quizzes, live collaboration)
- Widget needs to react to chat input (game rounds, live data refresh)
### Lifecycle
1. **Activated**: PiP stays fixed to viewport top on scroll
2. **Pinned**: Remains fixed until dismissed or session ends
3. **Session ends**: Returns to inline position, scrolls away
### Rules
- PiP state MUST update based on composer input
- Close automatically when session ends
- Don't overload with controls - use fullscreen for complex UI
---
## Visual Design Guidelines
### Color
Use system colors. Partner branding through accents only.
```
✅ DO:
- System colors for text, icons, dividers
- Brand accent on primary buttons
- Logo/icon for brand recognition
❌ DON'T:
- Custom backgrounds
- Override text colors
- Custom gradients breaking minimal look
```
### Typography
System fonts only (SF Pro iOS, Roboto Android).
```
✅ DO:
- Inherit system font stack
- Use body and body-small sizes
- Bold/italic for content emphasis
❌ DON'T:
- Custom fonts (even in fullscreen!)
- Excessive font size variation
- Style structural UI differently
```
### Spacing & Layout
- Use system grid spacing
- Consistent padding (no cramming)
- System corner radius
- Clear hierarchy: headline → supporting text → CTA
### Icons & Imagery
- Monochromatic, outlined icons (system or matching)
- DON'T include your logo in response (ChatGPT adds it)
- Enforced aspect ratios for images
### Accessibility (Required)
- WCAG AA contrast ratios
- Alt text for all images
- Text resizing support
- Focus states for keyboard navigation
---
## Design Components
### Reference Examples
From [openai-apps-sdk-examples](https://github.com/openai/openai-apps-sdk-examples):
| Component | Use Case |
|-----------|----------|
| **List** | Dynamic collections with empty states |
| **Map** | Geo data with marker clustering |
| **Album** | Media grids with fullscreen |
| **Carousel** | Featured content with swipe |
| **Shop** | Product browsing with checkout |
### Planning Checklist
1. **Viewer vs Editor?**
- Read-only (chart, dashboard) or editable (forms, kanban)?
2. **Single-shot vs Multiturn?**
- One invocation or state persists across turns?
3. **Inline vs Fullscreen?**
- Sketch states before implementing
4. **Data Requirements**
- Define JSON payload (structuredContent)
- Initial state from `window.openai.toolOutput`
- Subsequent data from `callTool` returns
- Use `setWidgetState` for caching
---
## State Contract
### Where State Lives
| State Type | Location | Example |
|------------|----------|---------|
| **Component State** | `setWidgetState()` | Selected record, scroll position, staged form |
| **Server State** | Your backend | Authoritative data, merge after tool calls |
| **Model Messages** | `sendFollowUpMessage()` | Human-readable updates to transcript |
### State Diagram Pattern
```
User clicks in widget
↓
callTool() to execute action
↓
Update component state (setWidgetState)
↓
Optionally sendFollowUpMessage for transcript
↓
Re-render with new data
```
---
## Compact Widget Design (Information-Dense)
### The Problem
Widgets that fetch data on every click are **inefficient**:
- Round-trip latency for each interaction
- Wastes API calls
- Poor user experience
### The Solution: Pre-load + Client-Side Expand
**Load all data upfront, reveal it client-side.**
```typescript
// Tool: Pre-load schemas for first 20 tables
const tables = await fetchTables();
const schemasToLoad = tables.slice(0, 20);
// Parallel fetch schemas
const schemas = await Promise.all(
schemasToLoad.map(t => fetchSchema(t.id))
);
return {
structuredContent: {
tables,
schemas: Object.fromEntries(
schemasToLoad.map((t, i) => [t.id, schemas[i]])
),
},
_meta: getWidgetMeta('table-explorer'),
};
```
### Design Guidelines
| Guideline | Value | Reason |
|-----------|-------|--------|
| **Max height** | 350px | Fits in conversation flow |
| **Click behavior** | Client-side expand | No round-trips |
| **Data loading** | Upfront for top N items | Instant reveals |
| **Animation** | CSS Grid 0fr→1fr | Smooth height transitions |
### UI Pattern: Expandable Rows
```
┌─────────────────────────────────────┐
│ 📊 Tables (47 tables) │
├─────────────────────────────────────┤
│ ▸ users 42,150 records │ ← Click to expand
│ ▾ transactions 1.2M records │ ← Expanded (schema visible)
│ ├─ id: integer (pk, autoincr) │
│ ├─ user_id: integer (fk → users) │
│ ├─ amount: decimal │
│ └─ created_at: timestamp │
│ ▸ products 2,340 records │
│ ▸ orders 156K records │
└─────────────────────────────────────┘
```
---
## CSS Grid Animation Technique
### The Problem
`height: auto` can't be animated with CSS transitions. Widgets need smooth expand/collapse.
### The Solution: CSS Grid 0fr → 1fr
```css
/* Container uses grid with row sizing */
.row-wrapper {
display: grid;
grid-template-rows: 0fr; /* Collapsed: 0 fractional units */
transition: grid-template-rows 250ms ease-out;
}
/* Expanded state */
.row.expanded .row-wrapper {
grid-template-rows: 1fr; /* Expanded: 1 fractional unit */
}
/* Inner content must have overflow: hidden */
.row-content {
overflow: hidden;
}
```
### Complete Example
```javascript
// Widget JS: Toggle expand on click
row.addEventListener('click', function() {
this.classList.toggle('expanded');
});
```
```css
/* Widget CSS: Smooth expand/collapse */
.te-row { cursor: pointer; }
.te-row:hover { background: #f9fafb; }
/* The magic: grid-template-rows animates height */
.te-detail-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 250ms ease-out;
}
.te-row.expanded .te-detail-wrapper {
grid-template-rows: 1fr;
}
/* Content must have overflow: hidden */
.te-detail {
overflow: hidden;
padding: 0 16px;
}
/* Only show padding when expanded */
.te-row.expanded .te-detail {
padding: 12px 16px;
}
```
### Why This Works
| Traditional Approach | Grid Approach |
|---------------------|---------------|
| `height: 0` → `height: auto` | `grid-template-rows: 0fr` → `1fr` |
| **Can't animate!** | **Animates smoothly!** |
| Need JS height calculation | Pure CSS solution |
| Janky or no animation | Butter-smooth 250ms |
### Browser Support
- Chrome 57+, Firefox 52+, Safari 10.1+, Edge 16+
- Essentially all modern browsers
- Falls back gracefully (instant expand/collapse)
---
## Quick Reference: Do's and Don'ts
### Cards
| Do | Don't |
|----|-------|
| 1-2 CTAs max | Deep navigation |
| Auto-fit height | Nested scrolling |
| System colors | Custom backgrounds |
| Single purpose | Replicate composer |
### Fullscreen
| Do | Don't |
|----|-------|
| Work WITH composer | Fight the overlay |
| Deepen engagement | Replicate native app |
| Handle chat input | Ignore conversation |
### Visual
| Do | Don't |
|----|-------|
| System fonts | Custom fonts |
| Brand accents | Override text colors |
| Consistent spacing | Cramped layouts |
| Alt text | Skip accessibility |
---
## Sources
- [OpenAI Apps SDK UI Guidelines](https://developers.openai.com/apps-sdk/concepts/ui-guidelines)
- [Apps SDK UI Design System](https://openai.github.io/apps-sdk-ui/)
- [Figma Component Library](https://www.figma.com/community/file/1560064615791108827)
- [openai-apps-sdk-examples](https://github.com/openai/openai-apps-sdk-examples)
---
**Key Takeaway**: Design WITH ChatGPT, not against it. The composer is always there. Embrace conversation-first UX.
ChatGPT Apps SDK enables interactive widgets that render inline in conversations. The pattern has three parts:
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ WIDGET TOOL │ │ UI RESOURCE │ │ WIDGET JS │
│ │ │ │ │ │
│ - Fetches data │ │ - Serves HTML │ │ - Reads data │
│ - Returns: │────▶│ - mimeType: │────▶│ - From window. │
│ structuredContent│ │ text/html+ │ │ openai.toolOutput│
│ + _meta with │ │ skybridge │ │ - Renders UI │
│ outputTemplate │ │ - Minimal HTML │ │ │
└────────────────────┘ └────────────────────┘ └────────────────────┘
Key insight: The tool returns DATA, the resource serves a TEMPLATE, and the JS renders DATA into the TEMPLATE.
typescript// src/tools/meta/widgets/inbox.ts
import { z } from 'zod';
import { formatError } from '../../../response/formatter';
// Schema
export const schema = z.object({
limit: z.number().min(1).max(50).default(20).describe('Max items to show'),
});
// Widget metadata - points to the resource
export function getWidgetMeta() {
return {
'openai/outputTemplate': 'ui://mymcp/list', // Resource URI
'openai/toolInvocation/invoking': 'Loading...', // Loading state text
'openai/toolInvocation/invoked': 'Ready', // Complete state text
'openai/widgetAccessible': true, // Accessibility flag
} as const;
}
// Handler
export async function handler(params: z.infer<typeof schema>) {
try {
// 1. Fetch data from your backend
const result = await myService.fetchItems(params.limit);
// 2. Transform to widget format
const items = result.map(item => ({
id: item.id,
title: item.name,
subtitle: item.description,
icon: '📧',
badge: item.isNew ? 'new' : undefined,
}));
// 3. Build structuredContent (this flows to the widget)
const structuredContent = {
title: `Items (${result.length} total)`,
items,
summary: {
total: result.length,
returned: items.length,
},
};
// 4. Return in OpenAI Apps SDK format
return {
content: [
{
type: 'text',
text: `Showing ${items.length} items`, // Fallback for non-widget contexts
},
],
structuredContent, // This becomes window.openai.toolOutput
_meta: getWidgetMeta(), // Points to the resource
};
} catch (error) {
return formatError(error.message, 500);
}
}
// Metadata
export const metadata = {
id: 'my_widget',
name: 'My Widget',
description: 'Display items as an interactive widget',
category: 'ui',
operation: 'read' as const,
};
typescript// src/index.ts
import * as myWidget from './tools/meta/widgets/my-widget';
this.server.registerTool(
myWidget.metadata.id,
{
title: myWidget.metadata.name,
description: myWidget.metadata.description,
inputSchema: myWidget.schema,
_meta: myWidget.getWidgetMeta(), // Include widget meta in registration
},
async (params) => {
const validated = myWidget.schema.parse(params);
return await myWidget.handler(validated);
}
);
typescript// src/resources/ui.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const SKYBRIDGE_MIME = 'text/html+skybridge'; // THE MAGIC MIMETYPE
const ASSETS_BASE = 'https://mymcp.workers.dev';
export function setupUIResources(server: McpServer): void {
// Register list widget resource
server.resource(
'ui-list', // Internal name
'ui://mymcp/list', // URI (matches outputTemplate)
{
title: 'List Widget',
description: 'Interactive list widget',
mimeType: SKYBRIDGE_MIME, // Required for widget rendering
},
async (resourceUri) => {
// Return minimal HTML that loads external JS/CSS
const html = `<div id="widget-root"></div>
<link rel="stylesheet" href="${ASSETS_BASE}/widgets/list.css">
<script src="${ASSETS_BASE}/widgets/list.js"></script>`;
return {
contents: [
{
uri: resourceUri.href,
mimeType: SKYBRIDGE_MIME,
text: html,
_meta: {
'openai/widgetPrefersBorder': true,
'openai/widgetDomain': 'https://chatgpt.com',
},
},
],
};
}
);
}
typescriptclass MyMCP extends McpAgent<any, MyState, MyAuthProps> {
async init() {
await this.setupTools();
setupUIResources(this.server); // Register resources
}
}
javascript// src/widgets/assets.ts - Served from /widgets/list.js
export const WIDGET_LIST_JS = `
(function() {
'use strict';
// 1. Get the root element
var root = document.getElementById('widget-root');
if (!root) {
console.error('[Widget] Root element not found');
return;
}
// 2. Get data from ChatGPT Apps SDK
var toolOutput = window.openai ? window.openai.toolOutput : null;
console.log('[Widget] toolOutput:', toolOutput);
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data available</div>';
return;
}
// 3. Extract data (handle both direct and nested structuredContent)
var data = toolOutput.structuredContent || toolOutput;
var title = data.title || 'Items';
var items = data.items || [];
// 4. Render the UI
var html = '<div class="header">' + escapeHtml(title) + '</div>';
if (items.length === 0) {
html += '<div class="empty">No items</div>';
} else {
html += '<div class="items">';
for (var i = 0; i < items.length; i++) {
var item = items[i];
html += '<div class="item">';
html += '<div class="icon">' + (item.icon || '📄') + '</div>';
html += '<div class="content">';
html += '<div class="title">' + escapeHtml(item.title || '') + '</div>';
if (item.subtitle) {
html += '<div class="subtitle">' + escapeHtml(item.subtitle) + '</div>';
}
html += '</div>';
if (item.badge) {
html += '<div class="badge">' + escapeHtml(item.badge) + '</div>';
}
html += '</div>';
}
html += '</div>';
}
root.innerHTML = html;
// Helper: escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
})();
`.trim();
javascriptexport const WIDGET_LIST_CSS = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
color: #1f2937;
background: #fff;
padding: 16px;
}
.header {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.items { max-height: 400px; overflow-y: auto; }
.item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #e5e7eb;
cursor: pointer;
}
.item:hover { background: #f9fafb; }
.item:last-child { border-bottom: none; }
.icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.content { flex: 1; min-width: 0; }
.title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtitle {
font-size: 13px;
color: #6b7280;
margin-top: 2px;
}
.badge {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
background: #3b82f6;
color: white;
text-transform: uppercase;
}
.empty {
padding: 32px;
text-align: center;
color: #9ca3af;
}
`.trim();
1. User: "Show my inbox"
↓
2. ChatGPT calls: inbox_widget({ limit: 20 })
↓
3. Tool fetches emails from backend
↓
4. Tool returns:
{
content: [{ type: 'text', text: 'Showing 20 emails' }],
structuredContent: {
title: 'Inbox (20 messages)',
items: [{ id: '1', title: 'John', subtitle: 'Hello!' }, ...]
},
_meta: {
'openai/outputTemplate': 'ui://mymcp/list'
}
}
↓
5. ChatGPT sees outputTemplate, fetches ui://mymcp/list
↓
6. Resource returns:
{
contents: [{
mimeType: 'text/html+skybridge',
text: '<div id="widget-root"></div><script src=".../list.js"></script>'
}]
}
↓
7. ChatGPT renders HTML in sandboxed iframe
↓
8. Script loads, reads window.openai.toolOutput
↓
9. Script renders items into widget-root
↓
10. User sees interactive widget inline!
| Property | Required | Description |
|---|---|---|
openai/outputTemplate |
Yes | Resource URI to load (e.g., ui://mymcp/list) |
openai/toolInvocation/invoking |
No | Text shown while loading |
openai/toolInvocation/invoked |
No | Text shown when complete |
openai/widgetAccessible |
No | Accessibility flag |
| Property | Required | Description |
|---|---|---|
openai/widgetPrefersBorder |
No | Show border around widget |
openai/widgetDomain |
No | Allowed domain (e.g., https://chatgpt.com) |
See my-mcp for the complete implementation:
src/tools/meta/widgets/inbox.tssrc/resources/ui.tssrc/widgets/assets.tssrc/index.tsUser: "Show me my email inbox"
ChatGPT: [Calls inbox_widget]
Result: Interactive widget renders showing emails inline!
This is the #1 cause of "wrong widget rendering" bugs.
When registering a tool in index.ts, the _meta in the registration object OVERRIDES the _meta returned by the handler. Both must match!
typescript// ❌ WRONG - Registration _meta points to OLD widget, handler returns NEW widget
this.server.registerTool(
'tables_widget',
{
title: 'Tables Widget',
inputSchema: tablesWidget.schema,
_meta: getWidgetMeta('tables'), // ← Points to OLD widget!
},
async (params) => {
// Handler returns new widget meta, but it gets IGNORED
return await tablesWidget.handler(params); // Returns getWidgetMeta('table-explorer')
}
);
// ✅ CORRECT - Both registration AND handler point to SAME widget
this.server.registerTool(
'tables_widget',
{
title: 'Tables Widget',
inputSchema: tablesWidget.schema,
_meta: getWidgetMeta('table-explorer'), // ← Matches handler!
},
async (params) => {
return await tablesWidget.handler(params); // Also returns 'table-explorer'
}
);
Debug checklist:
_meta in index.ts_metastructuredContent (not just content)_meta["openai/outputTemplate"] matches resource URItext/html+skybridge (not text/html)structuredContent objectWidgets can do MORE than just display data. See interactive-widgets.md for:
window.openai.callTool() - Invoke MCP tools from widget clickswindow.openai.sendFollowUpMessage() - Suggest actions to ChatGPTwindow.openai.setWidgetState() - Persist UI state across renders# ChatGPT Apps SDK Widgets
## Table of Contents
- [The Widget Pattern](#the-widget-pattern)
- [Widget Tool Implementation](#widget-tool-implementation)
- [Resource Registration](#resource-registration)
- [Widget JavaScript](#widget-javascript)
- [Data Flow](#data-flow)
- [_meta Properties](#_meta-properties)
- [Complete Example](#complete-example)
---
## The Widget Pattern
ChatGPT Apps SDK enables interactive widgets that render inline in conversations. The pattern has three parts:
```
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ WIDGET TOOL │ │ UI RESOURCE │ │ WIDGET JS │
│ │ │ │ │ │
│ - Fetches data │ │ - Serves HTML │ │ - Reads data │
│ - Returns: │────▶│ - mimeType: │────▶│ - From window. │
│ structuredContent│ │ text/html+ │ │ openai.toolOutput│
│ + _meta with │ │ skybridge │ │ - Renders UI │
│ outputTemplate │ │ - Minimal HTML │ │ │
└────────────────────┘ └────────────────────┘ └────────────────────┘
```
**Key insight**: The tool returns DATA, the resource serves a TEMPLATE, and the JS renders DATA into the TEMPLATE.
---
## Widget Tool Implementation
### Complete Widget Tool
```typescript
// src/tools/meta/widgets/inbox.ts
import { z } from 'zod';
import { formatError } from '../../../response/formatter';
// Schema
export const schema = z.object({
limit: z.number().min(1).max(50).default(20).describe('Max items to show'),
});
// Widget metadata - points to the resource
export function getWidgetMeta() {
return {
'openai/outputTemplate': 'ui://mymcp/list', // Resource URI
'openai/toolInvocation/invoking': 'Loading...', // Loading state text
'openai/toolInvocation/invoked': 'Ready', // Complete state text
'openai/widgetAccessible': true, // Accessibility flag
} as const;
}
// Handler
export async function handler(params: z.infer<typeof schema>) {
try {
// 1. Fetch data from your backend
const result = await myService.fetchItems(params.limit);
// 2. Transform to widget format
const items = result.map(item => ({
id: item.id,
title: item.name,
subtitle: item.description,
icon: '📧',
badge: item.isNew ? 'new' : undefined,
}));
// 3. Build structuredContent (this flows to the widget)
const structuredContent = {
title: `Items (${result.length} total)`,
items,
summary: {
total: result.length,
returned: items.length,
},
};
// 4. Return in OpenAI Apps SDK format
return {
content: [
{
type: 'text',
text: `Showing ${items.length} items`, // Fallback for non-widget contexts
},
],
structuredContent, // This becomes window.openai.toolOutput
_meta: getWidgetMeta(), // Points to the resource
};
} catch (error) {
return formatError(error.message, 500);
}
}
// Metadata
export const metadata = {
id: 'my_widget',
name: 'My Widget',
description: 'Display items as an interactive widget',
category: 'ui',
operation: 'read' as const,
};
```
### Register the Tool
```typescript
// src/index.ts
import * as myWidget from './tools/meta/widgets/my-widget';
this.server.registerTool(
myWidget.metadata.id,
{
title: myWidget.metadata.name,
description: myWidget.metadata.description,
inputSchema: myWidget.schema,
_meta: myWidget.getWidgetMeta(), // Include widget meta in registration
},
async (params) => {
const validated = myWidget.schema.parse(params);
return await myWidget.handler(validated);
}
);
```
---
## Resource Registration
### The text/html+skybridge Pattern
```typescript
// src/resources/ui.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const SKYBRIDGE_MIME = 'text/html+skybridge'; // THE MAGIC MIMETYPE
const ASSETS_BASE = 'https://mymcp.workers.dev';
export function setupUIResources(server: McpServer): void {
// Register list widget resource
server.resource(
'ui-list', // Internal name
'ui://mymcp/list', // URI (matches outputTemplate)
{
title: 'List Widget',
description: 'Interactive list widget',
mimeType: SKYBRIDGE_MIME, // Required for widget rendering
},
async (resourceUri) => {
// Return minimal HTML that loads external JS/CSS
const html = `<div id="widget-root"></div>
<link rel="stylesheet" href="${ASSETS_BASE}/widgets/list.css">
<script src="${ASSETS_BASE}/widgets/list.js"></script>`;
return {
contents: [
{
uri: resourceUri.href,
mimeType: SKYBRIDGE_MIME,
text: html,
_meta: {
'openai/widgetPrefersBorder': true,
'openai/widgetDomain': 'https://chatgpt.com',
},
},
],
};
}
);
}
```
### Call from init()
```typescript
class MyMCP extends McpAgent<any, MyState, MyAuthProps> {
async init() {
await this.setupTools();
setupUIResources(this.server); // Register resources
}
}
```
---
## Widget JavaScript
### The window.openai.toolOutput Pattern
```javascript
// src/widgets/assets.ts - Served from /widgets/list.js
export const WIDGET_LIST_JS = `
(function() {
'use strict';
// 1. Get the root element
var root = document.getElementById('widget-root');
if (!root) {
console.error('[Widget] Root element not found');
return;
}
// 2. Get data from ChatGPT Apps SDK
var toolOutput = window.openai ? window.openai.toolOutput : null;
console.log('[Widget] toolOutput:', toolOutput);
if (!toolOutput) {
root.innerHTML = '<div class="empty">No data available</div>';
return;
}
// 3. Extract data (handle both direct and nested structuredContent)
var data = toolOutput.structuredContent || toolOutput;
var title = data.title || 'Items';
var items = data.items || [];
// 4. Render the UI
var html = '<div class="header">' + escapeHtml(title) + '</div>';
if (items.length === 0) {
html += '<div class="empty">No items</div>';
} else {
html += '<div class="items">';
for (var i = 0; i < items.length; i++) {
var item = items[i];
html += '<div class="item">';
html += '<div class="icon">' + (item.icon || '📄') + '</div>';
html += '<div class="content">';
html += '<div class="title">' + escapeHtml(item.title || '') + '</div>';
if (item.subtitle) {
html += '<div class="subtitle">' + escapeHtml(item.subtitle) + '</div>';
}
html += '</div>';
if (item.badge) {
html += '<div class="badge">' + escapeHtml(item.badge) + '</div>';
}
html += '</div>';
}
html += '</div>';
}
root.innerHTML = html;
// Helper: escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
})();
`.trim();
```
### Widget CSS
```javascript
export const WIDGET_LIST_CSS = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
color: #1f2937;
background: #fff;
padding: 16px;
}
.header {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.items { max-height: 400px; overflow-y: auto; }
.item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #e5e7eb;
cursor: pointer;
}
.item:hover { background: #f9fafb; }
.item:last-child { border-bottom: none; }
.icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.content { flex: 1; min-width: 0; }
.title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtitle {
font-size: 13px;
color: #6b7280;
margin-top: 2px;
}
.badge {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
background: #3b82f6;
color: white;
text-transform: uppercase;
}
.empty {
padding: 32px;
text-align: center;
color: #9ca3af;
}
`.trim();
```
---
## Data Flow
```
1. User: "Show my inbox"
↓
2. ChatGPT calls: inbox_widget({ limit: 20 })
↓
3. Tool fetches emails from backend
↓
4. Tool returns:
{
content: [{ type: 'text', text: 'Showing 20 emails' }],
structuredContent: {
title: 'Inbox (20 messages)',
items: [{ id: '1', title: 'John', subtitle: 'Hello!' }, ...]
},
_meta: {
'openai/outputTemplate': 'ui://mymcp/list'
}
}
↓
5. ChatGPT sees outputTemplate, fetches ui://mymcp/list
↓
6. Resource returns:
{
contents: [{
mimeType: 'text/html+skybridge',
text: '<div id="widget-root"></div><script src=".../list.js"></script>'
}]
}
↓
7. ChatGPT renders HTML in sandboxed iframe
↓
8. Script loads, reads window.openai.toolOutput
↓
9. Script renders items into widget-root
↓
10. User sees interactive widget inline!
```
---
## _meta Properties
### Tool _meta (in tool response)
| Property | Required | Description |
|----------|----------|-------------|
| `openai/outputTemplate` | Yes | Resource URI to load (e.g., `ui://mymcp/list`) |
| `openai/toolInvocation/invoking` | No | Text shown while loading |
| `openai/toolInvocation/invoked` | No | Text shown when complete |
| `openai/widgetAccessible` | No | Accessibility flag |
### Resource _meta (in resource response)
| Property | Required | Description |
|----------|----------|-------------|
| `openai/widgetPrefersBorder` | No | Show border around widget |
| `openai/widgetDomain` | No | Allowed domain (e.g., `https://chatgpt.com`) |
---
## Complete Example
### Full Inbox Widget
See my-mcp for the complete implementation:
- Tool: `src/tools/meta/widgets/inbox.ts`
- Resource: `src/resources/ui.ts`
- Assets: `src/widgets/assets.ts`
- Registration: `src/index.ts`
### Testing
```
User: "Show me my email inbox"
ChatGPT: [Calls inbox_widget]
Result: Interactive widget renders showing emails inline!
```
---
## Common Issues
### CRITICAL: _meta Override Bug
**This is the #1 cause of "wrong widget rendering" bugs.**
When registering a tool in `index.ts`, the `_meta` in the registration object **OVERRIDES** the `_meta` returned by the handler. **Both must match!**
```typescript
// ❌ WRONG - Registration _meta points to OLD widget, handler returns NEW widget
this.server.registerTool(
'tables_widget',
{
title: 'Tables Widget',
inputSchema: tablesWidget.schema,
_meta: getWidgetMeta('tables'), // ← Points to OLD widget!
},
async (params) => {
// Handler returns new widget meta, but it gets IGNORED
return await tablesWidget.handler(params); // Returns getWidgetMeta('table-explorer')
}
);
// ✅ CORRECT - Both registration AND handler point to SAME widget
this.server.registerTool(
'tables_widget',
{
title: 'Tables Widget',
inputSchema: tablesWidget.schema,
_meta: getWidgetMeta('table-explorer'), // ← Matches handler!
},
async (params) => {
return await tablesWidget.handler(params); // Also returns 'table-explorer'
}
);
```
**Debug checklist:**
1. Check registration `_meta` in `index.ts`
2. Check handler's returned `_meta`
3. Both must point to the SAME resource URI
### Widget shows "No data available"
- Check that tool returns `structuredContent` (not just `content`)
- Verify `_meta["openai/outputTemplate"]` matches resource URI
### Widget doesn't render (shows HTML as text)
- Ensure resource mimeType is `text/html+skybridge` (not `text/html`)
- Check CORS headers on widget assets
### Widget shows loading forever
- External JS must be accessible (check /widgets/* endpoint)
- Check browser console for errors
### Data is null in window.openai.toolOutput
- Verify tool returns `structuredContent` object
- Check that ChatGPT environment supports Apps SDK
---
## Making Widgets Interactive
Widgets can do MORE than just display data. See [interactive-widgets.md](interactive-widgets.md) for:
- `window.openai.callTool()` - Invoke MCP tools from widget clicks
- `window.openai.sendFollowUpMessage()` - Suggest actions to ChatGPT
- `window.openai.setWidgetState()` - Persist UI state across renders
- Human-in-the-loop patterns (duplicate detection, action execution)
---
## Next Steps
- See [interactive-widgets.md](interactive-widgets.md) for human-in-the-loop patterns
- See [cdn-assets.md](cdn-assets.md) for hosting widget assets
- See [examples.md](examples.md) for more widget types