ChatGPT Apps Builder
Overview
This skill provides comprehensive guidance for implementing ChatGPT Apps using the OpenAI Apps SDK - a framework that enables MCP servers to deliver rich, interactive UI experiences directly within ChatGPT conversations.
Use this skill when:
- Building MCP servers that integrate with ChatGPT's widget runtime
- Creating interactive UI components that render inside ChatGPT conversations
- Implementing OAuth 2.1 authentication for user-specific data access
- Managing widget state and bidirectional communication with MCP servers
- Deploying and submitting apps to the ChatGPT platform
- Migrating from other approaches to the OpenAI Apps SDK framework
Core Concepts
What are ChatGPT Apps?
ChatGPT Apps are MCP-based applications with three key components:
- MCP Server: Defines tools, enforces auth, returns data, and links tools to UI templates
- Widget/UI Bundle: Renders inside ChatGPT's iframe using the
window.openairuntime - ChatGPT Model: Decides when to call tools and narrates the experience using structured data
Key Pattern: Tool → Data → Widget
User prompt
↓
ChatGPT model ──► MCP tool call ──► Your server ──► Tool response
│ │
│ ├─ structuredContent (model reads)
│ ├─ content (narration)
│ └─ _meta (widget-only data)
│ │
└───── renders narration ◄──── widget iframe ◄──────┘
(HTML template + window.openai)
Architecture Components
MCP Server:
- Registers UI templates as resources with MIME type
text/html+skybridge - Defines tools with
_meta["openai/outputTemplate"]pointing to UI resources - Returns three data payloads:
structuredContent,content, and_meta - Handles authentication and authorization
Widget Runtime (window.openai):
- Provides
toolInput,toolOutput,toolResponseMetadata,widgetState - Enables bidirectional communication:
callTool,sendFollowUpMessage - File handling:
uploadFile,getFileDownloadUrl - Layout controls:
requestDisplayMode,requestModal,notifyIntrinsicHeight - Context signals:
theme,displayMode,locale,userAgent
Security Model:
- Mandatory iframe sandboxing with Content Security Policy
openai/widgetCSPconfiguration for allowed domains- OAuth 2.1 with PKCE for user authentication
- Token verification on every MCP request
Implementation Workflow
Step 1: Set Up Your Project
Project structure:
your-chatgpt-app/
├─ server/
│ └─ src/index.ts # MCP server + tool handlers
├─ web/
│ ├─ src/component.tsx # React widget
│ └─ dist/app.{js,css} # Bundled assets
└─ package.json
Install dependencies:
# MCP SDK
npm install @modelcontextprotocol/sdk zod
# React widget
cd web
npm install react@^18 react-dom@^18
npm install -D vite esbuild typescript
Step 2: Register UI Templates
UI templates are MCP resources with text/html+skybridge MIME type:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { readFileSync } from "node:fs";
const server = new McpServer({ name: "my-app", version: "1.0.0" });
// Read bundled widget assets
const JS = readFileSync("web/dist/app.js", "utf8");
const CSS = readFileSync("web/dist/app.css", "utf8");
// Register widget template
server.registerResource(
"app-widget",
"ui://widget/app.html",
{},
async () => ({
contents: [
{
uri: "ui://widget/app.html",
mimeType: "text/html+skybridge",
text: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My App</title>
<style>${CSS}</style>
</head>
<body>
<div id="root"></div>
<script type="module">${JS}</script>
</body>
</html>
`.trim(),
_meta: {
"openai/widgetPrefersBorder": true,
"openai/widgetDomain": "https://chatgpt.com",
"openai/widgetCSP": {
connect_domains: ["https://api.yourservice.com"],
resource_domains: ["https://*.oaistatic.com"],
// Optional: redirect_domains, frame_domains
},
},
},
],
})
);
Template metadata:
openai/widgetPrefersBorder: Show visual border around widgetopenai/widgetDomain: Dedicated origin for widget (enables fullscreen button)openai/widgetCSP: Content Security Policy configurationconnect_domains: APIs the widget can fetch fromresource_domains: CDNs for static assets (images, fonts, scripts)redirect_domains: Hosts foropenExternalwithout safe-link modalframe_domains: Allowed iframe origins (discouraged, requires review)
Cache busting: When changing widget HTML/JS/CSS in breaking ways, use new URI or filename so ChatGPT loads fresh bundle.
Step 3: Define Tools
Tools are the contract the ChatGPT model reasons about:
import { z } from "zod";
server.registerTool(
"search_restaurants",
{
title: "Search Restaurants",
description: "Find restaurants matching criteria",
inputSchema: {
type: "object",
properties: {
location: { type: "string" },
cuisine: { type: "string" },
},
required: ["location"],
},
_meta: {
"openai/outputTemplate": "ui://widget/app.html",
"openai/toolInvocation/invoking": "Searching restaurants…",
"openai/toolInvocation/invoked": "Found restaurants",
"openai/widgetAccessible": true, // Allow widget to call this tool
"openai/visibility": "public", // "public" or "private"
},
},
async ({ location, cuisine }) => {
const restaurants = await searchRestaurants(location, cuisine);
return {
// Concise JSON for the model to read
structuredContent: {
count: restaurants.length,
topResults: restaurants.slice(0, 3).map(r => ({
name: r.name,
rating: r.rating,
})),
},
// Optional narration for the model's response
content: [
{
type: "text",
text: `Found ${restaurants.length} restaurants in ${location}`
}
],
// Large/sensitive data exclusively for the widget
_meta: {
allRestaurants: restaurants,
searchTimestamp: new Date().toISOString(),
},
};
}
);
Tool metadata:
openai/outputTemplate: URI of the widget to renderopenai/toolInvocation/invoking: Status message while tool executesopenai/toolInvocation/invoked: Status message when completeopenai/widgetAccessible: Enablewindow.openai.callToolfor this toolopenai/visibility:"public"(model + widget) or"private"(widget only)
Data payloads:
structuredContent: Concise JSON the model reads (keep under 4k tokens)content: Optional Markdown/plaintext narration_meta: Widget-exclusive data (never sent to model)
Design for idempotency: Model may retry calls, so handlers should be safe to execute multiple times.
Step 4: Build the Widget
React widget with window.openai:
// web/src/app.tsx
import { createRoot } from "react-dom/client";
import { useEffect, useState, useSyncExternalStore } from "react";
// Helper hook to read window.openai globals
function useOpenAiGlobal<K extends keyof typeof window.openai>(
key: K
): typeof window.openai[K] {
return useSyncExternalStore(
(onChange) => {
const handler = () => onChange();
window.addEventListener("openai:set_globals", handler);
return () => window.removeEventListener("openai:set_globals", handler);
},
() => window.openai[key]
);
}
// Helper hook for widget state persistence
function useWidgetState<T>(defaultValue: T) {
const savedState = useOpenAiGlobal("widgetState") as T;
const [state, setState] = useState<T>(savedState ?? defaultValue);
useEffect(() => {
if (savedState) setState(savedState);
}, [savedState]);
const setWidgetState = (newState: T | ((prev: T) => T)) => {
setState((prev) => {
const updated = typeof newState === "function"
? (newState as (prev: T) => T)(prev)
: newState;
window.openai.setWidgetState(updated);
return updated;
});
};
return [state, setWidgetState] as const;
}
function RestaurantList() {
const toolOutput = useOpenAiGlobal("toolOutput");
const theme = useOpenAiGlobal("theme");
const [selectedId, setSelectedId] = useWidgetState<string | null>(null);
const restaurants = toolOutput?._meta?.allRestaurants ?? [];
const handleRefresh = async () => {
await window.openai.callTool("search_restaurants", {
location: toolOutput?.location,
});
};
const handleFollowUp = async (restaurantName: string) => {
await window.openai.sendFollowUpMessage({
prompt: `Tell me more about ${restaurantName}`,
});
};
return (
<div className={`app ${theme}`}>
<h2>Restaurants</h2>
<button
{restaurants.map((r) => (
<div
key={r.id}
className={selectedId === r.id ? "selected" : ""}
=> setSelectedId(r.id)}
>
<h3>{r.name}</h3>
<p>Rating: {r.rating}⭐</p>
<button => handleFollowUp(r.name)}>
Learn More
</button>
</div>
))}
</div>
);
}
// Mount the app
const root = document.getElementById("root");
if (root) {
createRoot(root).render(<RestaurantList />);
}
Vanilla JavaScript version:
// web/src/app.ts
const toolOutput = window.openai.toolOutput;
const restaurants = toolOutput?._meta?.allRestaurants ?? [];
const root = document.getElementById("root");
root.innerHTML = `
<div class="restaurants">
<h2>Restaurants</h2>
${restaurants.map(r => `
<div class="card">
<h3>${r.name}</h3>
<p>Rating: ${r.rating}⭐</p>
</div>
`).join("")}
</div>
`;
// Subscribe to theme changes
window.addEventListener("openai:set_globals", () => {
document.body.className = window.openai.theme;
});
Step 5: Apply Theming
Use CSS variables that ChatGPT injects:
:root {
/* Fallback defaults */
--color-background-primary: light-dark(#ffffff, #171717);
--color-text-primary: light-dark(#171717, #fafafa);
--color-border: light-dark(#e5e5e5, #404040);
--font-sans: system-ui, -apple-system, sans-serif;
--border-radius-md: 8px;
--spacing-sm: 8px;
--spacing-md: 16px;
}
.app {
background: var(--color-background-primary);
color: var(--color-text-primary);
font-family: var(--font-sans);
padding: var(--spacing-md);
}
.card {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
padding: var(--spacing-md);
margin-bottom: var(--spacing-sm);
}
Optional: Use the Apps SDK UI kit at apps-sdk-ui for ready-made components.
Step 6: Bundle the Widget
Use Vite or esbuild to create single-file bundle:
// vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: "index.html"
}
}
});
Build command:
cd web
npm run build # Outputs dist/app.js and dist/app.css
Step 7: Test Locally
- Build widget:
npm run buildinweb/ - Start MCP server:
node dist/index.js - Use MCP Inspector:
- Point to
http://localhost:<port>/mcp - List resources and verify widget HTML
- Call tools and verify widget renders
- Test
window.openaiAPIs in browser devtools
- Point to
Step 8: Deploy
Requirements:
- HTTPS endpoint (ChatGPT requires HTTPS)
- During development:
ngrok http <port>or similar tunnel - Production: Deploy to Cloudflare Workers, Fly.io, Vercel, AWS, etc.
Test deployment:
# Tunnel localhost
ngrok http 3000
# Use ngrok URL when creating connector in ChatGPT
Authentication & Authorization
OAuth 2.1 Setup
Protected Resource Metadata (/.well-known/oauth-protected-resource):
{
"resource": "https://your-mcp.example.com",
"authorization_servers": [
"https://auth.yourcompany.com"
],
"scopes_supported": ["read", "write"],
"resource_documentation": "https://yourcompany.com/docs"
}
Authorization Server Metadata (.well-known/oauth-authorization-server):
{
"issuer": "https://auth.yourcompany.com",
"authorization_endpoint": "https://auth.yourcompany.com/oauth2/authorize",
"token_endpoint": "https://auth.yourcompany.com/oauth2/token",
"registration_endpoint": "https://auth.yourcompany.com/oauth2/register",
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["read", "write"]
}
Tool Security Schemes:
server.registerTool(
"get_user_data",
{
title: "Get User Data",
inputSchema: { /* ... */ },
securitySchemes: [
{ type: "oauth2", scopes: ["read"] }
],
_meta: {
"openai/outputTemplate": "ui://widget/app.html"
}
},
async ({ input }, context) => {
// Verify token
if (!context.authorization) {
return {
content: [{ type: "text", text: "Authentication required" }],
_meta: {
"mcp/www_authenticate": [
'Bearer resource_metadata="https://your-mcp.example.com/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="Login required"'
]
},
isError: true
};
}
// Verify token, scopes, audience, expiry
const user = await verifyToken(context.authorization);
const data = await fetchUserData(user.id);
return {
structuredContent: { summary: data.summary },
_meta: { fullData: data }
};
}
);
OAuth Flow:
- ChatGPT queries protected resource metadata
- ChatGPT dynamically registers client with authorization server
- User authenticates and grants scopes
- ChatGPT exchanges authorization code for access token (with PKCE)
- Token attached to MCP requests:
Authorization: Bearer <token> - MCP server verifies token on every request
Redirect URIs to allowlist:
- Production:
https://chatgpt.com/connector_platform_oauth_redirect - Review:
https://platform.openai.com/apps-manage/oauth
Advanced Features
Component-Initiated Tool Calls
Enable widgets to call tools directly:
// Tool definition
_meta: {
"openai/outputTemplate": "ui://widget/app.html",
"openai/widgetAccessible": true,
"openai/visibility": "public" // or "private" to hide from model
}
// Widget code
async function refreshData() {
const result = await window.openai.callTool("search_restaurants", {
location: "Paris"
});
// Widget automatically re-renders with new toolOutput
}
File Handling
Upload files:
// Widget code
async function handleUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
const { fileId } = await window.openai.uploadFile(file);
console.log("Uploaded:", fileId);
}
Download files:
// Widget code
const { downloadUrl } = await window.openai.getFileDownloadUrl({ fileId });
image.src = downloadUrl;
Tool file parameters:
server.registerTool(
"process_image",
{
title: "Process Image",
inputSchema: {
type: "object",
properties: {
image: {
type: "object",
properties: {
download_url: { type: "string" },
file_id: { type: "string" }
}
}
}
},
_meta: {
"openai/outputTemplate": "ui://widget/app.html",
"openai/fileParams": ["image"]
}
},
async ({ image }) => {
const response = await fetch(image.download_url);
const buffer = await response.arrayBuffer();
// Process image...
return { content: [], structuredContent: {} };
}
);
Display Modes
Request alternate layouts:
// Widget code
await window.openai.requestDisplayMode({ mode: "fullscreen" });
// Options: "inline", "pip", "fullscreen"
// Note: PiP may coerce to fullscreen on mobile
Modals
Spawn host-controlled overlays:
// Widget code
await window.openai.requestModal({
title: "Checkout",
component: "checkout-modal"
});
Navigation
Use standard routing (React Router):
import { BrowserRouter, Routes, Route, useNavigate } from "react-router-dom";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<ListView />} />
<Route path="/detail/:id" element={<DetailView />} />
</Routes>
</BrowserRouter>
);
}
function ListView() {
const navigate = useNavigate();
return (
<button => navigate("/detail/123")}>
View Details
</button>
);
}
ChatGPT mirrors iframe history to UI navigation controls.
Localization
Read locale and format accordingly:
// Widget code
const locale = window.openai.locale ?? "en-US";
const formatter = new Intl.DateTimeFormat(locale);
const price = new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD"
});
Or use i18n libraries:
import { IntlProvider } from "react-intl";
import en from "./locales/en-US.json";
import es from "./locales/es-ES.json";
const messages = { "en-US": en, "es-ES": es };
const locale = window.openai.locale ?? "en-US";
<IntlProvider locale={locale} messages={messages[locale]}>
<App />
</IntlProvider>
Close Widget
From widget:
window.openai.requestClose();
From server:
return {
content: [],
structuredContent: {},
_meta: {
"openai/closeWidget": true
}
};
Security & Privacy
Principles
- Least privilege: Only request needed scopes and permissions
- Explicit consent: Users understand what they're granting
- Defense in depth: Assume prompt injection and malicious inputs
Data Handling
structuredContent: Include only data needed for current prompt- Storage: Publish retention policy, honor deletion requests
- Logging: Redact PII, store correlation IDs only
Network Security
- Widgets run in sandboxed iframe with strict CSP
- No privileged browser APIs (
alert,prompt,clipboard) fetchallowed only with CSP compliance- Subframes blocked by default (require
frame_domains)
Authentication
- Use OAuth 2.1 with PKCE and dynamic client registration
- Verify token signature, issuer, audience, expiry on every request
- Reject expired/malformed tokens with
401andWWW-Authenticate
Prompt Injection Mitigation
- Review tool descriptions regularly
- Validate all inputs server-side
- Require human confirmation for destructive operations
- Test with injection prompts during QA
Testing & Deployment
Local Testing
MCP Inspector: Test tools and widget rendering
http://localhost:<port>/mcp- Verify resources list
- Call tools and inspect widget iframe
- Use browser devtools for
window.openaidebugging
Dogfooding: Test with trusted users before broad rollout
OAuth Testing: Use Inspector's Auth settings to walk through flow
Production Deployment
Hosting options:
- Cloudflare Workers
- Fly.io
- Vercel
- AWS Lambda/ECS
- Your existing infrastructure
Requirements:
- HTTPS endpoint
- Low latency (< 500ms tool responses recommended)
- OAuth protected resource metadata at
/.well-known/oauth-protected-resource - Authorization server with discovery metadata
Monitoring:
- Set up alerts for failed auth attempts
- Track tool execution times
- Monitor CSP violations in widget
- Log correlation IDs for debugging
Submission
Pre-submission checklist:
- OAuth redirect URIs allowlisted:
https://chatgpt.com/connector_platform_oauth_redirecthttps://platform.openai.com/apps-manage/oauth
- Tool descriptions clear and accurate
- Security review complete
- CSP correctly configured
- Widget tested across light/dark themes
- Mobile/desktop responsive design
- Localization for supported languages
- Error messages user-friendly
- Performance under load verified
- Documentation complete
Review process:
- Apps using
frame_domainssubject to stricter review - Expect questions about data handling and security
- May be rejected for directory if concerns remain
Troubleshooting
Widget Not Rendering
Symptom: White screen or no widget appears
Solutions:
- Verify
mimeType: "text/html+skybridge" - Check bundled JS/CSS URLs resolve correctly
- Inspect browser console for CSP violations
- Confirm
openai/widgetCSPallows necessary domains
window.openai Undefined
Symptom: Cannot read property 'toolOutput' of undefined
Solutions:
- Only available in
text/html+skybridgetemplates - Check MIME type spelling and format
- Verify widget loaded without errors
- Use browser devtools to inspect iframe
CSP Violations
Symptom: Network requests blocked, inline styles stripped
Solutions:
- Add domains to
connect_domains(APIs) - Add domains to
resource_domains(static assets) - Avoid inline scripts/styles (bundle them)
- Check browser console for specific CSP errors
Stale Widget Cache
Symptom: Old widget version keeps loading after deploy
Solutions:
- Change template URI:
ui://widget/app-v2.html - Change bundled filename:
app-20250101.js - Clear ChatGPT cache (hard refresh)
- Wait ~5 minutes for CDN propagation
Authentication Loops
Symptom: OAuth flow starts but never completes
Solutions:
- Verify redirect URI in allowlist
- Check
code_challenge_methods_supportedincludesS256 - Confirm
resourceparameter echoed in tokens - Use MCP Inspector Auth tab to debug flow step-by-step
- Check authorization server logs
Large Payloads
Symptom: Slow rendering, model performance degraded
Solutions:
- Trim
structuredContentto essentials (< 4k tokens) - Move large data to
_meta(widget-only) - Paginate or lazy-load data in widget
- Compress images before embedding
Examples
Official examples repository: openai-apps-sdk-examples
Pizzaz List
Ranked card list with favorites and CTAs
- Tool: Search pizzerias by location
- Widget: Sortable list with star ratings
- Features: Favorites, external links, follow-up prompts
Pizzaz Carousel
Embla-powered horizontal scroller for media-heavy layouts
- Tool: Fetch restaurant galleries
- Widget: Image carousel with lazy loading
- Features: Swipe navigation, fullscreen viewer
Pizzaz Map
Mapbox integration with fullscreen inspector
- Tool: Search restaurants near location
- Widget: Interactive map with markers
- Features: PiP mode, geolocation, clustering
Pizzaz Album
Stacked gallery for deep dives on single place
- Tool: Get restaurant details with photos
- Widget: Pinterest-style photo grid
- Features: Modal lightbox, share actions
Pizzaz Video
Scripted player with overlays
- Tool: Fetch restaurant promotional videos
- Widget: Custom video player
- Features: Overlay controls, transcript display
Best Practices
Server Design
- Idempotent handlers: Model may retry, design for safety
- Structured content first: Model reads this, keep it concise
- Metadata for widgets: Move large/sensitive data to
_meta - Clear tool descriptions: Model chooses tools based on these
- Security in depth: Verify tokens, validate inputs, log actions
Widget Design
- Theme-aware styling: Use CSS variables, test light/dark
- Responsive layouts: Mobile and desktop support
- Loading states: Handle async operations gracefully
- Error boundaries: Catch and display errors user-friendly
- Accessibility: Keyboard navigation, ARIA labels, focus management
State Management
- Persist with setWidgetState: Keep state under 4k tokens
- Scope to widget instance: State tied to specific message
- Clear on new conversations: Fresh
widgetId= empty state - Sync with useOpenAiGlobal: Multiple components stay consistent
- Validate rehydrated state: Handle corrupted or old state
Performance
- Bundle size: Keep under 500KB for fast load
- Code splitting: Lazy load features not needed immediately
- Image optimization: Compress, resize, use modern formats
- API caching: Cache responses when appropriate
- Debounce inputs: Avoid excessive tool calls from rapid interactions
Security
- Validate inputs: Never trust data from model or user
- Redact secrets: No tokens/keys in
structuredContentor_meta - CSP allowlist: Only necessary domains, avoid wildcards
- Rate limiting: Protect backend from abuse
- Audit logs: Track actions for compliance and debugging
Quick Reference
window.openai API
| Category | Property/Method | Description |
|---|---|---|
| Data | toolInput |
Tool arguments from invocation |
| Data | toolOutput |
structuredContent from server |
| Data | toolResponseMetadata |
_meta payload (widget-only) |
| Data | widgetState |
Persisted UI state snapshot |
| Data | setWidgetState(state) |
Store new state (< 4k tokens) |
| Tools | callTool(name, args) |
Invoke MCP tool from widget |
| Messaging | sendFollowUpMessage({ prompt }) |
Post user-authored message |
| Files | uploadFile(file) |
Upload file, receive fileId |
| Files | getFileDownloadUrl({ fileId }) |
Get temp download URL |
| Layout | requestDisplayMode({ mode }) |
Request inline/PiP/fullscreen |
| Layout | requestModal({ ... }) |
Spawn host modal overlay |
| Layout | notifyIntrinsicHeight(height) |
Report dynamic height |
| Layout | requestClose() |
Close the widget |
| Navigation | openExternal({ href }) |
Open vetted external link |
| Context | theme |
"light" or "dark" |
| Context | displayMode |
"inline", "pip", or "fullscreen" |
| Context | locale |
RFC 4647 locale string |
| Context | userAgent |
Client user agent |
| Context | maxHeight |
Container max height |
| Context | safeArea |
Safe area insets |
| Context | view |
View context info |
Tool Metadata Reference
| Key | Value | Description |
|---|---|---|
openai/outputTemplate |
"ui://widget/app.html" |
Widget URI to render |
openai/widgetAccessible |
true/false |
Enable callTool from widget |
openai/visibility |
"public"/"private" |
Model visibility |
openai/toolInvocation/invoking |
"Loading…" |
Status while executing |
openai/toolInvocation/invoked |
"Complete" |
Status when done |
openai/fileParams |
["image"] |
Fields treated as file params |
CSP Configuration
"openai/widgetCSP": {
connect_domains: ["https://api.example.com"], // Fetch destinations
resource_domains: ["https://cdn.example.com"], // Static assets
redirect_domains: ["https://checkout.example.com"], // openExternal
frame_domains: ["https://*.embed.example.com"] // Subframes (discouraged)
}
OAuth Endpoints
| Endpoint | Purpose |
|---|---|
/.well-known/oauth-protected-resource |
Protected resource metadata |
/.well-known/oauth-authorization-server |
OAuth 2.0 discovery |
/.well-known/openid-configuration |
OpenID Connect discovery |
Resources
Official Documentation:
Examples & Tools:
Community:
Authentication:
Related Skills
- mcp-builder: General MCP server implementation patterns
- mcp-mcp-apps-kit: MCP Apps (SEP-1865) for Claude Desktop
Converted and distributed by TomeVault — claim your Tome and manage your conversions.