
Chat Apps Ui Sdk
- 69 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
chat-apps-ui-sdk is a Claude Code skill for ai & agent building.
About
chat-apps-ui-sdk is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- chat-apps-ui-sdk
- AI & Agent Building
- AI-coding skill
Chat Apps Ui Sdk by the numbers
- 69 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,786 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill chat-apps-ui-sdkAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 69 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during AI-assisted development.?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with chat apps ui sdk.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during AI-assisted development., or when chat-apps-ui-sdk is a claude code skill for ai & agent building.
What you get
Structured output aligned to chat-apps-ui-sdk: chat-apps-ui-sdk, AI & Agent Building.
Files
Chat Apps UI SDK Best Practices
A reference for building beautiful, review-ready apps that render interactive UI inside the chat surface of ChatGPT and Claude. As of January 2026 these platforms share one foundation — the MCP Apps standard (@modelcontextprotocol/ext-apps), rendered by Claude, ChatGPT, VS Code, and Goose. The OpenAI Apps SDK is a superset adding the window.openai bridge, and MCP-UI (@mcp-ui/server / @mcp-ui/client) is the community implementation. This skill contains 46 rules across 8 categories, ordered by impact so the highest-leverage decisions come first.
The mental model: an app is an MCP tool first, UI second. The model invokes a tool; the tool returns data plus a link to a UI resource; the host renders that resource in a sandboxed iframe; the iframe talks back over a defined bridge. Mistakes early in that chain mean nothing renders; mistakes later mean it renders broken, unsafe, or unpolished.
When to Apply
Reference these guidelines when:
- Designing the MCP tool and
structuredContent/content/_metacontract for a chat app - Wiring a tool to a component (
_meta.ui.resourceUri, thetext/html;profile=mcp-appMIME type, theui://scheme) - Writing the iframe component and its bridge (
window.openai,ui/notifications/*, MCP-UIonUIAction) - Choosing display modes, widget state, theming, and responsive layout
- Hardening a chat app (CSP, sandbox, secrets, OAuth) or preparing it for directory submission
- Reviewing or refactoring existing ChatGPT-app / Claude-app / MCP-UI code
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | MCP Tool & Discovery Design | CRITICAL | tool- |
| 2 | UI Resource Wiring & Templates | CRITICAL | wire- |
| 3 | Host–Component Data Bridge | HIGH | bridge- |
| 4 | Display Modes & Responsive Layout | HIGH | display- |
| 5 | State & Model Context | HIGH | state- |
| 6 | Security & Data Boundaries | HIGH | sec- |
| 7 | Visual Design & UX Polish | MEDIUM-HIGH | design- |
| 8 | Distribution & Cross-Host Portability | MEDIUM | dist- |
Quick Reference
1. MCP Tool & Discovery Design (CRITICAL)
- `tool-structured-content-contract` - Split output across structuredContent, content, and _meta
- `tool-specific-verb-names` - Name tools as specific action verbs
- `tool-output-schema-validation` - Declare an output schema for structuredContent
- `tool-safety-annotations` - Set readOnlyHint and destructiveHint accurately
- `tool-minimal-scoped-inputs` - Request minimal, task-scoped tool inputs
- `tool-feed-widget-in-response` - Return everything the widget needs in one response
- `tool-honest-descriptions` - Write honest tool descriptions and status text
2. UI Resource Wiring & Templates (CRITICAL)
- `wire-resource-uri-link` - Link each tool to its UI with resourceUri
- `wire-mcp-app-mimetype` - Serve UI resources with the mcp-app MIME type
- `wire-ui-scheme-and-handler` - Match the ui:// URI to a registered resource
- `wire-version-uri-cache-key` - Version the resource URI as a cache key
- `wire-inline-self-contained-bundle` - Inline the component bundle into the resource
- `wire-set-ui-domain` - Set a unique ui.domain for the component
3. Host–Component Data Bridge (HIGH)
- `bridge-render-from-notifications` - Render from tool output, not first paint
- `bridge-call-tools-app-visibility` - Expose tools to the app before calling them
- `bridge-followup-vs-silent-call` - Choose follow-up messages or silent tool calls
- `bridge-validate-postmessage-origin` - Validate postMessage source in the host
- `bridge-handle-all-mcpui-actions` - Handle every MCP-UI onUIAction type
- `bridge-use-host-apis` - Use host bridge APIs instead of reimplementing
4. Display Modes & Responsive Layout (HIGH)
- `display-pick-the-right-mode` - Pick the display mode that fits the task
- `display-request-mode-with-fallback` - Request fullscreen but render inline first
- `display-report-intrinsic-height` - Report intrinsic height and respect maxHeight
- `display-avoid-nested-scroll` - Avoid nested scroll inside inline cards
- `display-respect-theme` - Respect the host theme and color scheme
- `display-responsive-breakpoints` - Collapse layout gracefully on small screens
5. State & Model Context (HIGH)
- `state-separate-three-stores` - Separate widget, server, and model state
- `state-persist-widget-state` - Persist UI state through setWidgetState
- `state-no-secrets-in-state` - Keep secrets and PII out of widget state
- `state-update-model-context` - Push user decisions to model context
- `state-keep-state-small` - Keep widget state small and serializable
6. Security & Data Boundaries (HIGH)
- `sec-declare-csp-allowlist` - Declare a CSP allowlist for the widget
- `sec-avoid-frame-domains` - Avoid nested frame domains in the widget
- `sec-no-secrets-in-payloads` - Never embed secrets in bundles or payloads
- `sec-enforce-server-side-auth` - Enforce authorization on the server
- `sec-signal-oauth-challenge` - Signal auth with a www-authenticate challenge
- `sec-minimize-restricted-data` - Minimize and avoid restricted data inputs
7. Visual Design & UX Polish (MEDIUM-HIGH)
- `design-inherit-native-typography` - Inherit native fonts and limit type sizes
- `design-restrain-brand-color` - Restrain brand color to accents
- `design-render-loading-empty-error` - Render loading, empty, and error states
- `design-meet-wcag-contrast` - Meet WCAG AA contrast and provide alt text
- `design-limit-actions-hierarchy` - Limit actions and keep a clear hierarchy
- `design-respect-reduced-motion` - Respect reduced-motion preferences
8. Distribution & Cross-Host Portability (MEDIUM)
- `dist-build-on-mcp-apps-standard` - Build on the shared MCP Apps standard
- `dist-degrade-without-ui` - Return a text fallback when UI is unsupported
- `dist-provide-submission-metadata` - Provide accurate submission metadata
- `dist-feature-detect-host-apis` - Detect host capabilities before use
How to Use
Read the individual reference files for full explanations and incorrect-vs-correct code examples. Start at the top — category 1 (tool-) and category 2 (wire-) gate whether anything renders at all, so resolve those before touching display or design.
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
- AGENTS.md - Compiled table of contents across all rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference URLs |
Related Skills
build-mcp-server— Entry point for designing the MCP server shape (deployment model, tool patterns) this skill's UI rules build on top of.
Chat Apps UI — OpenAI Apps SDK, MCP Apps & MCP-UI
Version 0.1.0 Chat Apps UI SDK May 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Architecture and design guide for building beautiful, review-ready apps that render interactive UI directly inside ChatGPT and Claude. Covers the shared MCP Apps standard (@modelcontextprotocol/ext-apps), the OpenAI Apps SDK window.openai bridge, and the MCP-UI SDK, with a Next.js/React backend. Contains 46 rules across 8 categories ordered by impact: from critical MCP tool design and UI-resource wiring (the data contract and the tool-to-component link that decide whether anything renders), through the host-component bridge, display modes, widget state, and sandbox/CSP security, down to visual-design polish and cross-host distribution. Each rule explains why it matters and shows production-realistic incorrect-vs-correct examples in TypeScript, TSX, or CSS, with explicit when-not-to-apply guidance. Sourced from the official OpenAI Apps SDK documentation, the Model Context Protocol MCP Apps specification, and the MCP-UI SDK.
---
Table of Contents
1. MCP Tool & Discovery Design — CRITICAL
- 1.1 Declare an Output Schema for structuredContent — CRITICAL (prevents widget render crashes from shape drift)
- 1.2 Name Tools as Specific Action Verbs — CRITICAL (prevents misrouted or unselectable tools)
- 1.3 Request Minimal, Task-Scoped Tool Inputs — HIGH (prevents privacy rejections and over-broad triggers)
- 1.4 Return Everything the Widget Needs in One Response — HIGH (eliminates client-side fetch waterfalls)
- 1.5 Set readOnlyHint and destructiveHint Accurately — CRITICAL (prevents unsafe auto-invocation and review rejection)
- 1.6 Split Tool Output Across structuredContent, content, and _meta — CRITICAL (prevents leaking private data to the model)
- 1.7 Write Honest Tool Descriptions and Status Text — HIGH (prevents over-triggering and confusing progress)
2. UI Resource Wiring & Templates — CRITICAL
- 2.1 Inline the Component Bundle Into the Resource — HIGH (eliminates a blank frame on cold boot)
- 2.2 Link Each Tool to Its UI With resourceUri — CRITICAL (prevents tools that never render a widget)
- 2.3 Match the ui:// URI to a Registered Resource — CRITICAL (prevents blank frames from URI mismatches)
- 2.4 Serve UI Resources With the mcp-app MIME Type — CRITICAL (prevents the host from rendering markup as text)
- 2.5 Set a Unique ui.domain for the Component — HIGH (prevents submission blocking and origin clashes)
- 2.6 Version the Resource URI as a Cache Key — HIGH (prevents stale widgets after a deploy)
3. Host–Component Data Bridge — HIGH
- 3.1 Choose Follow-Up Messages or Silent Tool Calls — HIGH (prevents chat spam and lost model context)
- 3.2 Expose Tools to the App Before Calling Them — HIGH (prevents rejected callTool requests)
- 3.3 Handle Every MCP-UI onUIAction Type — MEDIUM-HIGH (prevents silently dead UI controls)
- 3.4 Render From Tool Output, Not First Paint — HIGH (prevents a blank widget before data arrives)
- 3.5 Use Host Bridge APIs Instead of Reimplementing — MEDIUM-HIGH (prevents broken pickers and unvetted links)
- 3.6 Validate postMessage Source in the Host — HIGH (prevents spoofed bridge messages)
4. Display Modes & Responsive Layout — HIGH
- 4.1 Avoid Nested Scroll Inside Inline Cards — MEDIUM-HIGH (prevents scroll traps in the conversation)
- 4.2 Collapse Layout Gracefully on Small Screens — MEDIUM-HIGH (maintains usability on mobile widths)
- 4.3 Pick the Display Mode That Fits the Task — HIGH (prevents cramped or oversized widgets)
- 4.4 Report Intrinsic Height and Respect maxHeight — HIGH (prevents clipped content and dead space)
- 4.5 Request Fullscreen but Render Inline First — HIGH (prevents an empty widget when the host denies)
- 4.6 Respect the Host Theme and Color Scheme — MEDIUM-HIGH (prevents unreadable dark-mode widgets)
5. State & Model Context — HIGH
- 5.1 Keep Secrets and PII Out of Widget State — HIGH (prevents leaking tokens through state)
- 5.2 Keep Widget State Small and Serializable — MEDIUM (reduces per-turn round-trip size)
- 5.3 Persist UI State Through setWidgetState — HIGH (preserves selection across re-mounts)
- 5.4 Push User Decisions to Model Context — MEDIUM-HIGH (prevents incoherent follow-up turns)
- 5.5 Separate Widget, Server, and Model State — HIGH (prevents state drift across re-renders)
6. Security & Data Boundaries — HIGH
- 6.1 Avoid Nested Frame Domains in the Widget — MEDIUM-HIGH (prevents review rejection from embedding)
- 6.2 Declare a CSP Allowlist for the Widget — HIGH (prevents silently blocked API and image calls)
- 6.3 Enforce Authorization on the Server — HIGH (prevents spoofed client-hint access)
- 6.4 Minimize and Avoid Restricted Data Inputs — MEDIUM-HIGH (prevents privacy-policy rejection)
- 6.5 Never Embed Secrets in Bundles or Payloads — HIGH (prevents key exposure to end users)
- 6.6 Signal Auth With a www-authenticate Challenge — MEDIUM-HIGH (prevents a broken widget on unauthenticated calls)
7. Visual Design & UX Polish — MEDIUM-HIGH
- 7.1 Inherit Native Fonts and Limit Type Sizes — MEDIUM-HIGH (prevents a foreign, embedded-page look)
- 7.2 Limit Actions and Keep a Clear Hierarchy — MEDIUM-HIGH (prevents overwhelming inline cards)
- 7.3 Meet WCAG AA Contrast and Provide Alt Text — MEDIUM-HIGH (prevents accessibility review failures)
- 7.4 Render Loading, Empty, and Error States — MEDIUM-HIGH (prevents a blank frame during async work)
- 7.5 Respect Reduced-Motion Preferences — MEDIUM (prevents motion-triggered discomfort)
- 7.6 Restrain Brand Color to Accents — MEDIUM-HIGH (prevents an advertisement-like card)
8. Distribution & Cross-Host Portability — MEDIUM
- 8.1 Build on the Shared MCP Apps Standard — MEDIUM (enables one server across multiple hosts)
- 8.2 Detect Host Capabilities Before Use — MEDIUM (prevents crashes on hosts lacking an API)
- 8.3 Provide Accurate Submission Metadata — MEDIUM (prevents directory review rejection)
- 8.4 Return a Text Fallback When UI Is Unsupported — MEDIUM (maintains output on hosts without widgets)
---
References
1. https://developers.openai.com/apps-sdk 2. https://developers.openai.com/apps-sdk/reference 3. https://developers.openai.com/apps-sdk/build/mcp-server 4. https://developers.openai.com/apps-sdk/build/chatgpt-ui 5. https://developers.openai.com/apps-sdk/plan/components 6. https://developers.openai.com/apps-sdk/concepts/ui-guidelines 7. https://developers.openai.com/apps-sdk/app-submission-guidelines 8. https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps/ 9. https://modelcontextprotocol.io/ 10. https://mcpui.dev/ 11. https://mcpui.dev/guide/server/typescript/overview 12. https://mcpui.dev/guide/client/overview 13. https://github.com/MCP-UI-Org/mcp-ui
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Title}
{1-3 sentences explaining WHY this matters for chat apps — what breaks in the model routing, the iframe render, the bridge, or directory review without it, and what the agent should generalise. Teach the reasoning, not just the rule. State plainly when the pattern is overkill.}
Incorrect ({problem label}):
// Production-realistic anti-pattern. Comment explains the cost.
// Use ```tsx for component code, ```css for styling, ```json for manifests/metadata.Correct ({solution label}):
// Minimal diff from the incorrect example. Comment explains the benefit.When NOT to apply:
- {Realistic exception 1}
- {Realistic exception 2}
Reference: {Title}
<!-- Authoring notes for this skill:
- First tag MUST equal the file prefix (tool, wire, bridge, display, state, sec, design, dist).
- Title must equal the H2 exactly and start with an imperative verb.
- Code fences must declare a letter-only language (typescript, tsx, css, json).
- Avoid placeholder names (foo, temp, MyComponent) — use domain-realistic names.
- Avoid hedging ("might", "maybe") and marketing words ("seamless", "powerful").
- Cross-link related rules with [[rule-file-name-without-extension]].
- Run: node <plugin>/scripts/build-agents-md.js <skill-dir> then validate-skill.js <skill-dir>
-->
{
"version": "0.1.0",
"organization": "Chat Apps UI SDK",
"technology": "Chat Apps UI — OpenAI Apps SDK, MCP Apps & MCP-UI",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Architecture and design guide for building beautiful, review-ready apps that render interactive UI directly inside ChatGPT and Claude. Covers the shared MCP Apps standard (@modelcontextprotocol/ext-apps), the OpenAI Apps SDK window.openai bridge, and the MCP-UI SDK, with a Next.js/React backend. Contains 46 rules across 8 categories ordered by impact: from critical MCP tool design and UI-resource wiring (the data contract and the tool-to-component link that decide whether anything renders), through the host-component bridge, display modes, widget state, and sandbox/CSP security, down to visual-design polish and cross-host distribution. Each rule explains why it matters and shows production-realistic incorrect-vs-correct examples in TypeScript, TSX, or CSS, with explicit when-not-to-apply guidance. Sourced from the official OpenAI Apps SDK documentation, the Model Context Protocol MCP Apps specification, and the MCP-UI SDK.",
"references": [
"https://developers.openai.com/apps-sdk",
"https://developers.openai.com/apps-sdk/reference",
"https://developers.openai.com/apps-sdk/build/mcp-server",
"https://developers.openai.com/apps-sdk/build/chatgpt-ui",
"https://developers.openai.com/apps-sdk/plan/components",
"https://developers.openai.com/apps-sdk/concepts/ui-guidelines",
"https://developers.openai.com/apps-sdk/app-submission-guidelines",
"https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps/",
"https://modelcontextprotocol.io/",
"https://mcpui.dev/",
"https://mcpui.dev/guide/server/typescript/overview",
"https://mcpui.dev/guide/client/overview",
"https://github.com/MCP-UI-Org/mcp-ui"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. MCP Tool & Discovery Design (tool)
Impact: CRITICAL Description: Apps in ChatGPT and Claude are MCP tools first and UI second — if the model cannot discover, trigger, or correctly invoke your tool, no widget ever renders. The structuredContent / content / _meta response contract decides what the model sees, what the widget sees, and what stays private; getting it wrong leaks data to the model, starves the widget, or makes the tool unselectable. Every other category builds on a correctly designed, well-annotated tool.
2. UI Resource Wiring & Templates (wire)
Impact: CRITICAL Description: A tool renders UI only when it is wired to a UI resource correctly: the tool's _meta.ui.resourceUri must match a registered ui:// resource served with the text/html;profile=mcp-app MIME type, and the bundle must be self-contained because the iframe boots in isolation. A wrong URI, wrong MIME type, an un-versioned cache key, or an externally-dependent bundle produces a blank frame with no error — the most common and most confusing failure when building chat apps.
3. Host–Component Data Bridge (bridge)
Impact: HIGH Description: The component is a sandboxed iframe that talks to the host only through a defined bridge — window.openai plus the JSON-RPC ui/* methods on the MCP Apps standard, or onUIAction on MCP-UI. Reading data at the wrong time, calling tools the host hasn't exposed to the app, confusing a silent data call with a conversation turn, or trusting unvalidated postMessage origins are the dominant sources of runtime breakage and blank first paints.
4. Display Modes & Responsive Layout (display)
Impact: HIGH Description: Chat surfaces are narrow, resizable, themed, and shared with the conversation. Picking the wrong display mode (inline card vs carousel vs fullscreen vs picture-in-picture), hardcoding heights instead of reporting intrinsic height, nesting scroll containers, or ignoring theme and mobile breakpoints turns a working widget into one that clips content, traps scroll, or renders unreadable in dark mode.
5. State & Model Context (state)
Impact: HIGH Description: Widgets re-mount and re-render at the host's discretion, so component-local state is lost unless persisted through setWidgetState; meanwhile the model needs to stay aware of what the user did. Confusing ephemeral UI state, server-authoritative data, and model-visible context — or stuffing large or sensitive data into widget state — causes drift, lost work, incoherent follow-up turns, and bloated round-trips.
6. Security & Data Boundaries (sec)
Impact: HIGH Description: Everything the widget receives is user-visible and the iframe is sandboxed by default, so a missing CSP allowlist silently blocks your API, an embedded secret leaks to anyone who opens devtools, and client hints like user agent are trivially spoofed. Security here is also a distribution gate: missing CSP, nested frame domains, and over-collected restricted data are common review rejections.
7. Visual Design & UX Polish (design)
Impact: MEDIUM-HIGH Description: A chat app must feel native to the host, not like an embedded web page. Inheriting platform typography, restraining brand color, rendering explicit loading / empty / error states, meeting WCAG AA contrast, limiting actions per card, and respecting reduced-motion are what separate a beautiful, review-ready experience from one that reads as a cramped advertisement and fails approval.
8. Distribution & Cross-Host Portability (dist)
Impact: MEDIUM Description: The same MCP server can run inside Claude, ChatGPT, VS Code, and Goose if it is built on the shared MCP Apps standard and degrades gracefully where UI is unsupported. Hardcoding one vendor's bridge, returning UI with no useful text fallback, skipping submission metadata, or assuming a host capability without feature-detecting it limits reach and blocks the directory listing the app depends on.
Expose Tools to the App Before Calling Them
A widget can invoke server tools with window.openai.callTool (the JSON-RPC tools/call request), but only if the tool's descriptor allows app callers. The standard control is _meta.ui.visibility including "app"; ChatGPT also honors openai/widgetAccessible: true. Call a model-only tool from the iframe and the host rejects it — the click appears to do nothing because the rejection never surfaces in the UI.
Incorrect (tool is model-only; the widget's callTool is rejected):
server.registerTool("filter_seats", { inputSchema: { onlyWindow: z.boolean() } }, filterSeats);
// inside the component:
window.openai.callTool("filter_seats", { onlyWindow: true }); // rejected: not app-callableCorrect (mark the tool app-callable, then invoke it from the widget):
server.registerTool("filter_seats", {
inputSchema: { onlyWindow: z.boolean() },
_meta: { ui: { visibility: ["model", "app"] } }, // app callers allowed
}, filterSeats);
window.openai.callTool("filter_seats", { onlyWindow: true });Keep model-only tools (["model"]) for actions the user should not be able to trigger directly from the widget.
Reference: Build your ChatGPT UI – Apps SDK
Choose Follow-Up Messages or Silent Tool Calls
The bridge offers two ways to act, with opposite effects on the conversation. sendFollowUpMessage (the ui/message method) injects a user turn the model responds to; callTool runs a tool quietly and updates the widget without adding to the transcript. Use a follow-up when the user wants the model to react, and a silent call for in-widget data operations. Swapping them either floods the chat with noise or hides an action the model needed to see.
Incorrect (every filter click injects a chat turn; the transcript fills with noise):
const onlyDirect = () => window.openai.sendFollowUpMessage({ prompt: "show only direct flights" });Correct (filter silently in-widget; reserve a follow-up for a decision the model should act on):
const onlyDirect = () => window.openai.callTool("filter_flights", { direct: true }); // silent refine
const book = (id: string) => window.openai.sendFollowUpMessage({ prompt: `Book flight ${id}` }); // model actsA useful test: if the model would have nothing meaningful to say about the action, it should be a silent callTool.
Reference: Build your ChatGPT UI – Apps SDK
Handle Every MCP-UI onUIAction Type
With MCP-UI's UIResourceRenderer, the iframe emits typed actions through a single onUIAction callback: tool, prompt, link, intent, and notify, each with its own payload shape. Handle only tool and the link buttons, prompt chips, and toasts in the same widget silently do nothing — which users read as a broken app. Route every action type the component can emit.
Incorrect (only tool actions wired; link and prompt controls no-op):
<UIResourceRenderer resource={res}
onUIAction={(a) => { if (a.type === "tool") client.callTool(a.payload.toolName, a.payload.params); }} />Correct (route each action type to its host behavior):
<UIResourceRenderer resource={res} onUIAction={(a) => {
switch (a.type) {
case "tool": return client.callTool(a.payload.toolName, a.payload.params);
case "prompt": return sendUserTurn(a.payload.prompt);
case "link": return openExternal(a.payload.url);
case "intent": return routeIntent(a.payload.intent, a.payload.params);
case "notify": return toast(a.payload.message);
}
}} />Reference: MCP-UI client overview
Render From Tool Output, Not First Paint
The component's data may not be present at the very first paint, and updates arrive as events — openai:set_globals on the Apps SDK, or ui/notifications/tool-result on the MCP Apps bridge. Snapshotting window.openai.toolOutput once at module load captures whatever happened to be there and ignores everything after, so the widget shows empty until something unrelated forces a re-render. Read on mount and subscribe to updates.
Incorrect (snapshots data once at module load; later updates never reach the UI):
const out = window.openai.toolOutput; // may be undefined here
render(<Seatmap seats={out.seats} />);Correct (read on mount, then re-render whenever the host pushes new globals):
function Seatmap() {
const [out, setOut] = useState(window.openai.toolOutput);
useEffect(() => {
const onSet = () => setOut(window.openai.toolOutput);
window.addEventListener("openai:set_globals", onSet);
return () => window.removeEventListener("openai:set_globals", onSet);
}, []);
return <SeatGrid seats={out?.seats ?? []} />;
}Always guard against missing data (out?.seats ?? []) and render a loading state until it arrives (see [[design-render-loading-empty-error]]).
Reference: Reference – Apps SDK
Use Host Bridge APIs Instead of Reimplementing
The host exposes first-class operations — uploadFile, selectFiles, openExternal, requestModal — that integrate with its file store, link vetting, and modal chrome. Rebuilding a raw <input type="file"> or a target="_blank" anchor inside the sandbox bypasses that integration and often fails outright against the iframe's sandbox restrictions, so the control silently does nothing.
Incorrect (a raw file input and a new-tab anchor fight the sandbox):
<input type="file" onChange={(e) => uploadDirect(e.target.files![0])} />
<a href={ticketUrl} target="_blank" rel="noreferrer">Open ticket</a>Correct (use host operations that integrate with its file store and link vetting):
<button onClick={async () => { const files = await window.openai.selectFiles(); attach(files); }}>Attach</button>
<button onClick={() => window.openai.openExternal({ href: ticketUrl })}>Open ticket</button>These APIs are extensions, so feature-detect them on hosts that may not implement them (see [[dist-feature-detect-host-apis]]).
Reference: Reference – Apps SDK
Validate postMessage Source in the Host
When you implement the host side of the bridge with raw postMessage instead of the SDK, an unguarded listener accepts messages from any frame on the page. A malicious embed could then post a forged tools/call envelope and drive your tools. Verify that the message came from the widget iframe and accept only the JSON-RPC methods you expect before dispatching.
Incorrect (accepts messages from any frame; a hostile embed can drive tool calls):
window.addEventListener("message", (e) => handleRpc(e.data));Correct (verify the sender is the widget iframe and allowlist methods):
window.addEventListener("message", (e) => {
if (e.source !== widgetFrame.contentWindow) return; // must be our iframe
const msg = e.data;
if (msg?.jsonrpc !== "2.0" || !ALLOWED_METHODS.has(msg.method)) return; // known methods only
handleRpc(msg);
});Prefer the official App / AppRenderer bridge, which performs this validation for you; hand-rolled listeners are where origin checks get forgotten.
Reference: MCP Apps – Bringing UI to MCP clients
Inherit Native Fonts and Limit Type Sizes
An app that ships a custom web font and a dozen type sizes reads as a foreign page pasted into the chat. Inherit the host font stack, keep to body and body-small sizes, and avoid decorative gradients so the widget reads as part of the conversation rather than an advertisement. Restraint here is what makes a chat app feel native.
Incorrect (custom font, many sizes, and a gradient make the card look like an ad):
.card { font-family: "Pacifico", cursive; background: linear-gradient(#f0f, #0ff); }
.title { font-size: 28px; }
.meta { font-size: 9px; }Correct (inherit the host type, keep two readable sizes, flat surface):
.card { font: inherit; background: var(--surface); }
.title { font-size: var(--text-body); font-weight: 600; }
.meta { font-size: var(--text-body-sm); }Reference: UI guidelines – Apps SDK
Limit Actions and Keep a Clear Hierarchy
An inline card should offer one primary action and at most one secondary; piling five equal-weight buttons into a small card destroys hierarchy and makes the user hunt for the next step. Lead with a headline, then supporting detail, then a single primary call to action, and move the long tail of actions into fullscreen where there is room for them.
Incorrect (five equal-weight buttons flatten the hierarchy):
<Card>
{["Book", "Hold", "Share", "Compare", "Details"].map((label) => <button key={label}>{label}</button>)}
</Card>Correct (one primary, one secondary; extra actions move to fullscreen):
<Card>
<h3>{flight.route}</h3>
<p>{flight.times}</p>
<button className="primary">Book</button>
<button className="secondary" onClick={openDetails}>Details</button>
</Card>Reference: UI guidelines – Apps SDK
Meet WCAG AA Contrast and Provide Alt Text
Widgets must clear WCAG AA contrast, carry alt text on meaningful images, expose visible keyboard focus, and survive text resizing — both for real usability and because accessibility gaps block approval. The usual offenders are pale gray captions on white and icon-only buttons with no accessible label, which are invisible to low-vision and screen-reader users.
Incorrect (low-contrast caption and an unlabeled icon button):
<span style={{ color: "#bbbbbb" }}>Departs 09:40</span>
<button><StarIcon /></button>Correct (AA-contrast text, a labeled control, and a visible focus ring):
<span style={{ color: "var(--text-secondary)" }}>Departs 09:40</span>
<button aria-label="Save flight" className="focus-ring"><StarIcon /></button>Reference: UI guidelines – Apps SDK
Render Loading, Empty, and Error States
Tool calls and in-widget refreshes take time and sometimes fail. Without explicit loading, empty, and error states the user stares at a blank iframe and assumes the app is broken. Render a skeleton while data loads, a clear message when a query returns nothing, and a retry affordance on failure — every state the component can be in should look intentional.
Incorrect (renders nothing until data exists; failures look like a frozen app):
return <ul>{flights?.map((f) => <FlightRow key={f.id} flight={f} />)}</ul>;Correct (every state is visible and recoverable):
if (status === "loading") return <SkeletonList rows={5} />;
if (status === "error") return <ErrorPanel message="Couldn't load flights." onRetry={refetch} />;
if (flights.length === 0) return <EmptyState message="No flights match these dates." />;
return <ul>{flights.map((f) => <FlightRow key={f.id} flight={f} />)}</ul>;Reference: Design components – Apps SDK
Respect Reduced-Motion Preferences
Auto-playing carousels, parallax, and large transitions inside a chat are distracting and can cause discomfort or trigger vestibular conditions. Gate any non-essential animation behind prefers-reduced-motion and keep the remaining transitions short and subtle, so the widget stays calm next to a conversation the user is reading.
Incorrect (motion always on; ignores the user's reduced-motion setting):
.carousel { animation: auto-advance 3s infinite; }
.row { transition: transform 600ms ease; }Correct (disable non-essential motion when the user asks for less):
@media (prefers-reduced-motion: reduce) {
.carousel { animation: none; }
.row { transition: none; }
}Reference: Design components – Apps SDK
Restrain Brand Color to Accents
Use the host's surface and text colors for structure, and apply your brand only to buttons, badges, and small accents. Flooding the background with a brand color or dropping a logo banner into the response reads as advertising, fights the host's light and dark themes, and fails design review. The brand should be a tasteful accent, not the canvas.
Incorrect (brand floods the surface and a logo banner dominates the card):
return (
<div style={{ background: "#6d28d9", color: "#ffffff" }}>
<img src={logo} height={48} alt="brand logo" />
{body}
</div>
);Correct (neutral host surface; brand only on the primary action):
return (
<div style={{ background: "var(--surface)", color: "var(--text)" }}>
{body}
<button style={{ background: "#6d28d9", color: "#ffffff" }}>Reserve</button>
</div>
);Reference: UI guidelines – Apps SDK
Avoid Nested Scroll Inside Inline Cards
The chat transcript already scrolls and the iframe grows to its reported content height, so an inner overflow: auto container creates a scroll trap: the user's wheel or trackpad gesture gets captured by the inner box and the conversation stops scrolling. Let an inline card grow to fit its content and reserve internal scrolling for fullscreen, where the widget owns the whole surface.
Incorrect (inner scroll container traps the wheel inside the transcript):
.results { max-height: 300px; overflow-y: auto; }Correct (let the card grow; the transcript handles scrolling):
.results { height: auto; } /* reserve overflow:auto for fullscreen mode only */A horizontally-scrolling carousel is a deliberate exception — horizontal gestures do not fight the vertical transcript.
Reference: UI guidelines – Apps SDK
Pick the Display Mode That Fits the Task
The host offers four surfaces and each suits a different shape of task: an inline card for one quick result with at most two actions, a carousel for 3–8 browsable items, fullscreen for multi-step canvases and maps, and picture-in-picture for persistent, live activities. Forcing a 28-row table into an inline card makes everything tiny and clipped; opening fullscreen for a single confirmation steals the screen. Match the mode to the content density and interaction depth.
Incorrect (28 listings stuffed into a fixed inline card; rows are tiny and clipped):
return <div className="card">{listings.map((l) => <Row key={l.id} listing={l} />)}</div>;Correct (few items browse as a carousel; a long list summarizes and opens fullscreen):
return listings.length <= 8
? <Carousel items={listings} />
: <SummaryCard count={listings.length}
onSeeAll={() => window.openai.requestDisplayMode({ mode: "fullscreen" })} />;Reference: UI guidelines – Apps SDK
Report Intrinsic Height and Respect maxHeight
The host sizes the iframe from the height the widget reports — notifyIntrinsicHeight on the Apps SDK, or autoResizeIframe on MCP-UI — and caps it at window.openai.maxHeight. Hardcoding a pixel height clips tall content behind an invisible boundary or leaves a band of empty space below short content. Measure the rendered content, report it, and clamp to the host maximum.
Incorrect (fixed height clips long content and wastes space on short content):
return <div style={{ height: 600 }}>{children}</div>;Correct (report measured height, clamped to the host maximum):
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const measured = ref.current!.scrollHeight;
window.openai.notifyIntrinsicHeight?.(Math.min(measured, window.openai.maxHeight ?? measured));
});
return <div ref={ref}>{children}</div>;On MCP-UI, pass htmlProps={{ autoResizeIframe: true }} to UIResourceRenderer to get the same behavior without manual measurement.
Reference: Reference – Apps SDK
Request Fullscreen but Render Inline First
requestDisplayMode is a request the host can deny — on mobile, for policy, or because the user dismissed it. Calling it on mount and rendering nothing until it is granted leaves a blank widget whenever the request fails. Always paint a usable inline state first, then upgrade to fullscreen or picture-in-picture in response to an explicit user action, and keep working if the upgrade never happens.
Incorrect (requests fullscreen on mount and renders nothing until granted):
useEffect(() => { window.openai.requestDisplayMode({ mode: "fullscreen" }); }, []);
if (window.openai.displayMode !== "fullscreen") return null; // blank if the host deniesCorrect (usable inline immediately; upgrade on user intent, tolerate denial):
return (
<Card>
<TripSummary trip={trip} />
<button onClick={() => window.openai.requestDisplayMode({ mode: "fullscreen" })}>Open planner</button>
</Card>
);Reference: UI guidelines – Apps SDK
Respect the Host Theme and Color Scheme
The host exposes window.openai.theme and updates it through the openai:set_globals event when the user toggles appearance. A widget hardcoded to a white background with dark text turns into dark-on-dark — unreadable — the moment the user switches to dark mode. Drive colors from the theme and the CSS color-scheme property, and re-render when the theme changes.
Incorrect (hardcoded light palette becomes unreadable in dark mode):
return <div style={{ background: "#ffffff", color: "#111111" }}>{children}</div>;Correct (drive colors from the host theme and update on change):
const [theme, setTheme] = useState(window.openai.theme);
useEffect(() => {
const on = () => setTheme(window.openai.theme);
window.addEventListener("openai:set_globals", on);
return () => window.removeEventListener("openai:set_globals", on);
}, []);
return <div style={{ colorScheme: theme, background: "var(--surface)", color: "var(--text)" }}>{children}</div>;Reference: Design components – Apps SDK
Collapse Layout Gracefully on Small Screens
The same widget renders in a wide desktop panel and a narrow mobile sheet, so a fixed multi-column layout overflows and gets clipped on phones. Set a max width and use breakpoints (or intrinsic CSS grid) so columns stack instead of overflowing, and keep the primary action inside the safe area where the user can reach it.
Incorrect (fixed three-column width overflows the mobile sheet):
return <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 240px)" }}>{cards}</div>;Correct (auto-fit columns collapse to one on narrow widths):
return (
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
{cards}
</div>
);Test at both extremes; a layout that looks balanced on desktop frequently breaks at the mobile width the host uses on phones.
Reference: Design components – Apps SDK
Build on the Shared MCP Apps Standard
The standard MCP Apps keys — _meta.ui.resourceUri and the JSON-RPC ui/* bridge — render from one server in Claude, ChatGPT, VS Code, and Goose. Building only on a single vendor's surface (reading data exclusively through window.openai, linking UI only with openai/outputTemplate) locks the app to one host. Treat the openai/* fields and window.openai extensions as additive enhancements behind capability checks, not as the foundation.
Incorrect (ChatGPT-only keys; the widget renders nowhere else):
server.registerTool("show_board", { _meta: { "openai/outputTemplate": "ui://board/v1.html" } }, getBoard);Correct (standard key first; vendor extras layered on):
server.registerTool("show_board", { _meta: {
ui: { resourceUri: "ui://board/v1.html" }, // renders in Claude, ChatGPT, VS Code, Goose
"openai/outputTemplate": "ui://board/v1.html", // additive ChatGPT alias
} }, getBoard);Reference: MCP Apps – Bringing UI to MCP clients
Return a Text Fallback When UI Is Unsupported
Some hosts and model-only contexts will not render your widget at all. If the answer lives only in _meta for the component, those users get nothing useful. Always include meaningful structuredContent plus a natural-language content summary so the response stands on its own, and treat the widget as an enhancement layered on top of a complete text answer.
Incorrect (data only in _meta for the widget; a UI-less host shows nothing):
return { _meta: { items: order.items } };Correct (complete text answer first; the widget enhances it):
return {
structuredContent: { orderId: order.id, status: order.status, eta: order.eta },
content: [{ type: "text", text: `Order ${order.id} is ${order.status}, arriving ${order.eta}.` }],
_meta: { items: order.items }, // rich detail for the widget when it renders
};Reference: MCP Apps – Bringing UI to MCP clients
Detect Host Capabilities Before Use
Not every host implements every bridge method. Calling window.openai.requestDisplayMode or uploadFile on a host that lacks it throws and white-screens the widget. Probe for the method before calling it and provide a graceful path when it is absent, so the same component degrades cleanly instead of crashing on the hosts that support fewer extensions.
Incorrect (throws on a host that doesn't implement picture-in-picture):
const goPip = () => window.openai.requestDisplayMode({ mode: "pip" });Correct (probe first, fall back to an inline expansion):
const goPip = () => {
if (typeof window.openai?.requestDisplayMode === "function") {
window.openai.requestDisplayMode({ mode: "pip" });
} else {
setExpanded(true); // inline fallback where PiP is unavailable
}
};Reference: Reference – Apps SDK
Provide Accurate Submission Metadata
Directory review needs a specific (non-generic) name, a description that matches actual behavior, a published privacy policy, correctly-sized screenshots, and a working demo account. Generic names, missing privacy policies, and trial or demo builds are rejected. Prepare this metadata as part of the build, not as an afterthought once the code is done.
Incorrect (generic name, no privacy policy, a demo build):
{ "name": "Assistant", "description": "Does many things", "privacyPolicyUrl": null, "status": "demo" }Correct (specific name, honest description, required policy and screenshots):
{
"name": "Transit Live Arrivals",
"description": "Real-time bus and train arrivals for a stop you name.",
"privacyPolicyUrl": "https://transit.example.com/privacy",
"screenshots": ["inline-card.png", "fullscreen-map.png"]
}Reference: App submission guidelines – Apps SDK
Avoid Nested Frame Domains in the Widget
Embedding third-party iframes via frameDomains widens the attack surface and draws extra review scrutiny; the platform documentation explicitly discourages it, and it is a frequent rejection cause. Render the content inline or from your own vetted, CSP-listed origin instead of nesting an untrusted frame inside the sandbox.
Incorrect (embeds a third-party frame; discouraged and commonly rejected):
const csp = { frameDomains: ["https://widgets.thirdparty.example.com"] };
const html = `<iframe src="https://widgets.thirdparty.example.com/chart"></iframe>`;Correct (render the chart inline from your own bundle and origin):
const csp = { connectDomains: ["https://api.transit.example.com"] }; // no frameDomains
const html = `<div id="root"></div><script type="module">${chartBundle}</script>`;When NOT to apply:
- A genuinely unavoidable embed (a payment provider's hosted field) may require a frame — declare the single origin, expect review questions, and document why.
Reference: Build your MCP server – Apps SDK
Declare a CSP Allowlist for the Widget
The widget runs under a restrictive sandbox Content Security Policy. Anything not declared in _meta.ui.csp — connectDomains for fetch/XHR/WebSocket, resourceDomains for images and fonts — is blocked, and the only signal is a console error the user never sees, so the widget renders empty or missing its imagery. Declare exactly the origins your component contacts and no more.
Incorrect (no CSP; the sandbox blocks the map tiles and the widget renders empty):
return { contents: [{ uri, mimeType: "text/html;profile=mcp-app", text: html }] };Correct (declare exactly the origins the component talks to):
return { contents: [{ uri, mimeType: "text/html;profile=mcp-app", text: html, _meta: { ui: {
csp: {
connectDomains: ["https://api.transit.example.com"],
resourceDomains: ["https://tiles.transit.example.com"],
},
} } }] };Keep the lists minimal — a broad allowlist both weakens security and draws extra review scrutiny.
Reference: Build your MCP server – Apps SDK
Enforce Authorization on the Server
Client-supplied hints like openai/userAgent, openai/locale, and coarse openai/userLocation are conveniences for personalization, not credentials — they are trivially forged by anything that can post to your server. Make every authorization decision inside the MCP server and its backing API against a verified session or OAuth token, never by trusting a value the iframe or host passed in.
Incorrect (trusts a forgeable client hint to grant privileged access):
if (meta["openai/userAgent"]?.includes("Internal")) return adminReport(); // spoofableCorrect (authorize from a verified token on the server):
const session = await verifySession(req); // throws on invalid or expired token
if (!session.roles.includes("admin")) throw new Error("forbidden");
return adminReport(session.userId);Reference: Build your MCP server – Apps SDK
Minimize and Avoid Restricted Data Inputs
Do not request restricted categories — payment card numbers, health records, government IDs, or precise location — in your input schema. Collect the minimum a task needs and take coarse, host-supplied metadata for location rather than raw coordinates. Over-collection is both a safety risk and a documented review-rejection reason, so design schemas defensively from the start.
Incorrect (collects restricted financial and precise-location fields directly):
const inputSchema = {
creditCardNumber: z.string(),
cvv: z.string(),
exactLatLng: z.tuple([z.number(), z.number()]),
};Correct (take a tokenized reference and coarse location):
const inputSchema = {
paymentRef: z.string(), // tokenized at the payment provider
city: z.string().optional(), // precise location via client metadata, not a tool input
};Reference: App submission guidelines – Apps SDK
Never Embed Secrets in Bundles or Payloads
structuredContent, content, _meta, widget state, and the inlined bundle are all delivered to the user's browser. An API key baked into the component or returned in a payload is readable by anyone who opens devtools, and a leaked live key is an incident. Keep secrets on the server and have the widget reach third-party APIs only by calling your own authenticated tool, which uses the key server-side.
Incorrect (key shipped to the browser inside the bundle and the payload):
const html = `<script>const MAPS_KEY="AIzaSyA8_live_…";</script>${bundle}`;
return { structuredContent: { ok: true }, _meta: { stripeKey: "sk_live_51H…" } };Correct (secrets stay server-side; the widget calls your tool, which holds the key):
const html = `<div id="root"></div><script type="module">${bundle}</script>`; // no secrets inlined
return { structuredContent: { ok: true } }; // widget calls back through an authenticated toolReference: Build your MCP server – Apps SDK
Signal Auth With a www-authenticate Challenge
When a tool needs the user to sign in, return the RFC 7235 challenge in _meta["mcp/www_authenticate"] so the host can run its own OAuth flow. Rendering an error widget or a custom login form instead leaves the user stuck — the sandbox cannot complete a real authentication flow, and the host has no way to know sign-in is required.
Incorrect (renders an error the sandbox can't turn into a real login):
return { structuredContent: { error: "Please log in to view orders" } };Correct (return the standard challenge so the host runs its OAuth flow):
return {
content: [{ type: "text", text: "Sign in to continue." }],
_meta: { "mcp/www_authenticate": 'Bearer realm="orders", error="invalid_token"' },
};The challenge is only half the flow: the host starts sign-in when the tool also declares per-tool securitySchemes metadata and the server exposes /.well-known/oauth-protected-resource. Ship both halves, not just the runtime error.
Reference: Reference – Apps SDK
Keep Widget State Small and Serializable
Widget state is serialized and transported with each turn, so storing whole result sets or non-serializable values (DOM nodes, class instances) there bloats every round-trip and can exceed the host's size limit, after which persistence silently fails. Persist identifiers and view flags; re-derive the heavy data from the tool result, which the widget already received.
Incorrect (whole result set in widget state; serialized and shipped every turn):
window.openai.setWidgetState({ allListings: listings }); // hundreds of objects per round-tripCorrect (persist identifiers and view flags; re-derive heavy data from tool output):
window.openai.setWidgetState({ selectedId: listing.id, sort: "price" });Reference: Reference – Apps SDK
Keep Secrets and PII Out of Widget State
widgetState, structuredContent, and content are all delivered to the user's browser, and widgetState additionally round-trips through the host on every turn. Storing an access token or full personal data there exposes it in devtools and in transport. Persist only opaque identifiers and let the server resolve them to the sensitive value behind authentication.
Incorrect (token and PII persisted in widget state; visible in devtools and transport):
window.openai.setWidgetState({ accessToken: "atk_live_8Q2x…", userEmail });Correct (persist an opaque id; the server maps it to the token):
window.openai.setWidgetState({ sessionId });The same rule applies to anything you return from a tool — never embed secrets in payloads the user can read (see [[sec-no-secrets-in-payloads]]).
Reference: Build your MCP server – Apps SDK
Persist UI State Through setWidgetState
The host can unmount and re-mount the widget between turns, and component-local useState is wiped when it does. Anything you write with setWidgetState comes back on window.openai.widgetState after the re-mount, so persist the state the user would resent losing — active filters, a draft message, the selected tab. Seed component state from widgetState on mount and write through on every change.
Incorrect (active tab is local state; it resets every time the host re-mounts):
const [tab, setTab] = useState<"map" | "list">("map");Correct (seed from persisted state and write through on change):
const [tab, setTab] = useState<"map" | "list">(window.openai.widgetState?.tab ?? "map");
const select = (t: "map" | "list") => {
setTab(t);
window.openai.setWidgetState({ ...window.openai.widgetState, tab: t });
};Keep what you persist small and serializable (see [[state-keep-state-small]]) — widgetState is transported on every turn.
Reference: Reference – Apps SDK
Separate Widget, Server, and Model State
Chat apps have three stores with three owners, and conflating them is the root of most state bugs. Ephemeral UI state (the active tab, a selection) belongs in setWidgetState; the source of truth (the booking, the order) belongs in your backend, reached through a tool call; model-visible facts (what the user just chose) go through the model-context update. Keep one fact in one place so the widget, server, and model never disagree.
Incorrect (everything in local React state; lost on re-mount, invisible to server and model):
const [seat, setSeat] = useState<string | null>(null);
const choose = (s: string) => setSeat(s); // nothing persisted, reserved, or told to the modelCorrect (route each fact to its owner):
const app = new App(); // MCP Apps bridge instance
const choose = (s: string) => {
window.openai.setWidgetState({ ...window.openai.widgetState, seat: s }); // ephemeral UI
window.openai.callTool("reserve_seat", { flightId, seat: s }); // server source of truth
app.updateModelContext({ content: [{ type: "text", text: `Selected seat ${s}.` }] }); // model in the loop
};app is the MCP Apps bridge (new App() from @modelcontextprotocol/ext-apps); on the Apps SDK alone, the same three responsibilities map to setWidgetState, callTool, and the model-context update.
Reference: Design components – Apps SDK
Push User Decisions to Model Context
When the user acts inside the widget — picks a date, selects a row, toggles an option — the model cannot see it unless you call the model-context update (ui/update-model-context, exposed as app.updateModelContext). Skip it and the next turn the model contradicts the visible UI, asking "which date did you want?" because it never learned the choice the user already made on screen.
Incorrect (user picks a date in the widget; the model is never told):
const pick = (iso: string) => setDate(iso);Correct (tell the model what changed so the next turn stays coherent):
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App();
const pick = (iso: string) => {
setDate(iso);
app.updateModelContext({ content: [{ type: "text", text: `User chose the ${iso} departure.` }] });
};Update model context for decisions the model should reason about — not for every hover or scroll, which would just add noise.
Reference: MCP Apps – Bringing UI to MCP clients
Return Everything the Widget Needs in One Response
The widget boots inside a sandbox with no shared session, so if it has to call your API again after mount to fill in data, the user watches a second spinner and you end up re-implementing authentication inside the iframe. Put the full first-paint dataset in the tool result — concise fields in structuredContent, heavy rows in _meta — so the component renders immediately from data it already has.
Incorrect (returns ids only; the component re-fetches details on mount):
// Widget mounts, then makes a second authenticated round-trip behind a spinner:
return { structuredContent: { orderId: order.id, status: order.status } };Correct (hydrate the widget in the same response; big rows kept off the model):
return {
structuredContent: { orderId: order.id, status: order.status, totalUsd: order.totalUsd },
_meta: { items: order.items, timeline: order.timeline }, // first paint needs no extra round-trip
};When NOT to apply:
- Genuinely live data (a streaming price, a moving vehicle) should refresh via a tool call after first paint rather than ship a stale snapshot.
Reference: Design components – Apps SDK
Write Honest Tool Descriptions and Status Text
The description is the model's routing signal, so a description that begs for broad triggering ("use this for anything about travel") causes misfires and is rejected at review, while an accurate, scoped one keeps routing tight. Pair it with short openai/toolInvocation status text so the user sees legible progress while the tool runs instead of a silent pause.
Incorrect (begs the model to over-trigger; no progress shown):
server.registerTool("book_stay", {
description: "Use this for anything about travel, trips, or vacations.",
}, bookStay);Correct (scoped description plus host-shown progress, each under 64 chars):
server.registerTool("book_stay", {
description: "Book a specific hotel room for given dates after the user picks a property.",
_meta: {
"openai/toolInvocation/invoking": "Checking availability…",
"openai/toolInvocation/invoked": "Availability ready",
},
}, bookStay);Describe what the tool does and the precondition for using it; let the model decide when, rather than instructing it to fire broadly.
Reference: Build your MCP server – Apps SDK
Request Minimal, Task-Scoped Tool Inputs
An input schema should ask for exactly what the task needs and nothing more. Requesting the full conversation, raw transcripts, or broad contextual fields widens the model's trigger surface so the tool fires when it shouldn't, and it trips privacy review for over-collection. Narrow inputs make the model's decision to call the tool precise and keep the data you handle to a minimum.
Incorrect (asks for the whole transcript and identity it doesn't need):
server.registerTool("summarize_thread", {
inputSchema: { conversationHistory: z.array(z.string()), userEmail: z.string(), threadId: z.string() },
}, summarizeThread);Correct (ask only for the identifier the task operates on):
server.registerTool("summarize_thread", { inputSchema: { threadId: z.string() } }, summarizeThread);If the task genuinely needs user context, resolve it server-side from an authenticated session rather than accepting it as a model-supplied argument (see [[sec-enforce-server-side-auth]]).
Reference: App submission guidelines – Apps SDK
Declare an Output Schema for structuredContent
Declaring an outputSchema turns the shape of structuredContent into a contract the host can validate and the widget can trust. Without it, a backend field rename or a null from an upstream API silently ships malformed data, and the component renders undefined or throws on first paint with no useful error in the chat. Validate against the schema before returning so failures surface on the server, not in the user's iframe.
Incorrect (no declared shape; a renamed field reaches the widget as undefined):
server.registerTool("get_portfolio", { inputSchema: { accountId: z.string() } },
async ({ accountId }) => ({ structuredContent: await fetchPortfolio(accountId) }));Correct (typed contract, validated before it leaves the server):
const PortfolioOut = z.object({
totalUsd: z.number(),
holdings: z.array(z.object({ ticker: z.string(), shares: z.number() })),
});
server.registerTool("get_portfolio",
{ inputSchema: { accountId: z.string() }, outputSchema: PortfolioOut.shape },
async ({ accountId }) => ({ structuredContent: PortfolioOut.parse(await fetchPortfolio(accountId)) }));The same schema that protects the widget also documents the data shape for the model, improving how it reasons about and narrates the result.
Reference: Build your MCP server – Apps SDK
Set readOnlyHint and destructiveHint Accurately
Hosts read readOnlyHint, destructiveHint, and openWorldHint to decide whether to run a tool automatically or pause for explicit user confirmation. Labeling a state-changing tool as read-only invites the host to fire it silently — cancelling an order the user never confirmed. Missing or incorrect action labels are one of the most common causes of directory-review rejection, so annotate every tool to match what it actually does.
Incorrect (a state-changing tool with no hints; the host may auto-run it):
server.registerTool("cancel_order", { inputSchema: { orderId: z.string() } }, cancelOrder);Correct (annotations mark it as a destructive write needing confirmation):
server.registerTool("cancel_order", {
inputSchema: { orderId: z.string() },
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
}, cancelOrder);Read-only lookups should set readOnlyHint: true; anything that mutates external state needs destructiveHint, and anything that reaches the open internet needs openWorldHint.
Reference: App submission guidelines – Apps SDK
Name Tools as Specific Action Verbs
The model routes to a tool by reading its name and description against the user's intent. A generic name like search or run collides with every other installed app and rarely gets selected, while a specific verb-plus-noun name (get_flight_status) makes routing deterministic. Vague names are also a documented directory-review rejection cause.
Incorrect (generic name competes with every app):
// The model can't tell when to pick this over a dozen other "search" tools:
server.registerTool("search", { description: "Search and return results" }, lookupHandler);Correct (verb + domain noun the model routes to unambiguously):
server.registerTool("get_flight_status", {
title: "Get Flight Status",
description: "Look up live status, gate, and delay for one flight number on a given date.",
}, getFlightStatus);Keep names unique within your app and human-readable; the title is shown in UI affordances while the name is the routing key.
Reference: App submission guidelines – Apps SDK
Split Tool Output Across structuredContent, content, and _meta
A tool result has three channels with three different audiences, and collapsing them is the single most consequential mistake in chat-app design. structuredContent is read by both the model and the widget — keep it small and meaningful. content is natural-language narration the model speaks back. _meta is delivered only to the widget and never reaches the model. Dumping everything into content floods the model with raw rows and starves the widget of typed data; putting private detail in structuredContent sends it straight to the model.
Incorrect (whole dataset in model-visible text):
// 40 rows serialized into content -> the model reads all of it, the widget gets nothing typed:
return { content: [{ type: "text", text: JSON.stringify(flights) }] };Correct (three audiences, three channels):
return {
structuredContent: { origin, destination, cheapestUsd: flights[0].priceUsd }, // model + widget read this
content: [{ type: "text", text: `Found ${flights.length} flights to ${destination}.` }], // model narrates
_meta: { flights }, // full rows for the widget only — never sent to the model
};Keep structuredContent concise: it counts against the model's context on every turn. Heavy rows belong in _meta, which the widget reads via the bridge (see [[bridge-render-from-notifications]]).
Reference: Build your MCP server – Apps SDK
Inline the Component Bundle Into the Resource
The iframe loads the resource HTML in isolation. If that HTML pulls your JavaScript or CSS from your own origin at runtime, a CSP gap or a cold cache shows a blank frame before anything paints, and you have introduced a network dependency on a surface that should boot instantly. Bundle the component and inline it into the HTML so the document is self-sufficient and renders the moment the host mounts it.
Incorrect (the iframe must fetch your origin before it can paint):
const html = `<div id="root"></div><script src="https://flighty.example.com/seatmap.js"></script>`;
return { contents: [{ uri, mimeType: "text/html;profile=mcp-app", text: html }] };Correct (single self-contained document; nothing to fetch to boot):
const bundle = await readFile("dist/seatmap.js", "utf8"); // built with esbuild --bundle --format=esm
const html = `<div id="root"></div><script type="module">${bundle}</script>`;
return { contents: [{ uri, mimeType: "text/html;profile=mcp-app", text: html }] };Runtime data still comes through the bridge — only the code is inlined, not the dataset (see [[tool-feed-widget-in-response]]).
Reference: Build your ChatGPT UI – Apps SDK
Serve UI Resources With the mcp-app MIME Type
The host decides whether an HTML resource becomes an interactive widget purely from its MIME type. The MCP Apps standard is text/html;profile=mcp-app (older ChatGPT builds used text/html+skybridge). Serve a generic text/html and the host has no signal that this is a renderable component, so it prints your markup as a code block in the transcript.
Incorrect (generic html type; the host shows the markup as text):
return { contents: [{ uri: "ui://seatmap/v2.html", mimeType: "text/html", text: html }] };Correct (the mcp-app profile tells the host to render a widget):
return { contents: [{ uri: "ui://seatmap/v2.html", mimeType: "text/html;profile=mcp-app", text: html }] };If you must support older ChatGPT clients alongside the standard, detect the host and fall back to text/html+skybridge; new integrations target the profile MIME type.
Reference: Build your MCP server – Apps SDK
Link Each Tool to Its UI With resourceUri
A tool renders a component only when its descriptor carries _meta.ui.resourceUri pointing at a registered UI resource. This is the standard MCP Apps key; ChatGPT also accepts the alias openai/outputTemplate, which maps to the same thing. Omit it and the tool returns text with no widget, every single time — there is no implicit linkage between a tool and a component.
Incorrect (no UI link; the tool result renders as plain text):
server.registerTool("show_seatmap", { inputSchema: { flightId: z.string() } }, getSeatmap);Correct (standard key links the tool to its component; alias kept for ChatGPT back-compat):
server.registerTool("show_seatmap", {
inputSchema: { flightId: z.string() },
_meta: {
ui: { resourceUri: "ui://seatmap/v2.html" }, // MCP Apps standard
"openai/outputTemplate": "ui://seatmap/v2.html", // additive ChatGPT alias
},
}, getSeatmap);Prefer the standard ui.resourceUri so the same server renders across hosts (see [[dist-build-on-mcp-apps-standard]]); treat the openai/ alias as additive, not primary.
Reference: MCP Apps – Bringing UI to MCP clients
Set a Unique ui.domain for the Component
Each app declares _meta.ui.domain, a dedicated origin the host uses to sandbox the widget — it renders under that domain's isolated sandbox host. The value must be unique per app and is required for directory submission; omitting it blocks the listing and can cause widgets from different apps to share an origin, which breaks storage isolation and CSP scoping.
Incorrect (no domain; the host can't assign a sandbox origin and submission is blocked):
return { contents: [{ uri, mimeType: "text/html;profile=mcp-app", text: html }] };Correct (unique origin per app, declared on the resource):
return { contents: [{ uri, mimeType: "text/html;profile=mcp-app", text: html, _meta: { ui: {
domain: "https://seatmap.flighty.example.com",
prefersBorder: true,
} } }] };prefersBorder is a separate rendering hint that asks the host to frame the widget as a bordered card; set it when the content reads better contained.
Reference: Build your MCP server – Apps SDK
Match the ui:// URI to a Registered Resource
The string in a tool's resourceUri must be byte-for-byte equal to a resource you actually register under the ui:// scheme. A typo, a stray hyphen, or a tool pointing at a URI no resource serves resolves to nothing — the frame stays blank with no error surfaced in the chat, which makes this failure maddening to debug. Share one constant between the tool and the resource so they cannot drift.
Incorrect (tool and resource disagree by one character; the frame stays blank):
server.registerTool("show_seatmap", { _meta: { ui: { resourceUri: "ui://seat-map/v2.html" } } }, getSeatmap);
server.registerResource("seatmap", "ui://seatmap/v2.html", {}, serveSeatmap); // note: seatmap vs seat-mapCorrect (a single shared constant guarantees they agree):
const SEATMAP_URI = "ui://seatmap/v2.html";
server.registerTool("show_seatmap", { _meta: { ui: { resourceUri: SEATMAP_URI } } }, getSeatmap);
server.registerResource("seatmap", SEATMAP_URI, {}, serveSeatmap);Reference: MCP-UI server overview
Version the Resource URI as a Cache Key
Hosts cache UI resources by their URI. If you ship new markup or a new bundle under the same ui:// URI, returning users keep rendering the old cached widget while you see the new one locally — a confusing split that looks like a flaky deploy. Treat the URI as your cache key and bump a version segment whenever the contents change so the host fetches fresh bytes.
Incorrect (markup changed but the URI did not; cached old widget keeps rendering):
const SEATMAP_URI = "ui://seatmap/board.html";
function template() { return renderBoardV3(); } // new code, same key -> users still see the old layoutCorrect (version in the URI; a new bundle gets a new cache key and reaches users):
const SEATMAP_URI = "ui://seatmap/board-v3.html";
function template() { return renderBoardV3(); }A content hash (ui://seatmap/board-7f3a.html) works equally well and automates the bump in CI.
Reference: Build your MCP server – Apps SDK
Related skills
FAQ
What does chat-apps-ui-sdk do?
chat-apps-ui-sdk is a Claude Code skill for ai & agent building.
When should I use chat-apps-ui-sdk?
When you need to helps with ai & agent building tasks during AI-assisted development., or when chat-apps-ui-sdk is a claude code skill for ai & agent building.
What are the main capabilities?
chat-apps-ui-sdk; AI & Agent Building; AI-coding skill.