Building a custom ChatGPT app allows you to extend ChatGPT's capabilities with your own tools, data sources, and interactive UI components. In this comprehensive guide, you'll learn how to create a ChatGPT app from scratch using the OpenAI Apps SDK and the Model Context Protocol (MCP).
By the end of this tutorial, you'll have a working ChatGPT app with a backend MCP server, custom tools, and an interactive widget that renders directly inside the ChatGPT interface.
What Is a ChatGPT App?
A ChatGPT app (formerly called "connector") is an extension that lets ChatGPT interact with external services, data sources, and custom interfaces. Apps can:
- Expose tools that ChatGPT can call to perform actions (create tasks, query databases, send messages)
- Return structured data that the model can understand and narrate
- Render custom UI widgets inside the ChatGPT conversation
- Authenticate users via OAuth to access their data securely
The Apps SDK is built on the Model Context Protocol (MCP), an open specification for connecting large language models to external tools and resources.
Prerequisites and Requirements
Before you start building, make sure you have:
- Node.js 18+ or Python 3.10+ installed
- A ChatGPT Plus, Pro, or Business subscription (developer mode requires a paid plan)
- Basic knowledge of TypeScript/JavaScript or Python
- Familiarity with REST APIs and HTTP servers
- ngrok or similar tool for local development tunneling
To test custom apps, you need to enable Developer Mode in ChatGPT. Go to Settings โ Apps & Connectors โ Advanced settings and toggle on Developer Mode. For Business/Enterprise plans, workspace admins must enable this first.
Understanding the Model Context Protocol (MCP)
The Model Context Protocol is the backbone of ChatGPT apps. It standardizes how AI models communicate with external services. An MCP server implements three core capabilities:
1. Tools
Tools are functions that ChatGPT can call during a conversation. Each tool has a name, description, and JSON Schema defining its input and output parameters. For example, a task management app might expose tools like create_task, list_tasks, and complete_task.
2. Resources
Resources are data sources that provide contextual information. In the Apps SDK, UI templates are exposed as resources with the special MIME type text/html+skybridge, which tells ChatGPT to render them as interactive widgets.
3. Transports
MCP supports multiple transport protocols. For ChatGPT apps, you'll use Streamable HTTP (recommended) or Server-Sent Events (SSE). The transport handles the communication layer between ChatGPT and your server.
Setting Up Your Project
Let's create a simple to-do list app. Start by setting up your project structure:
your-chatgpt-app/
โโโ server/
โ โโโ src/
โ โโโ index.ts # MCP server + tool handlers
โโโ web/
โ โโโ public/
โ โโโ widget.html # UI widget
โโโ package.json
โโโ tsconfig.json
Initialize the project
Create a new directory and initialize npm:
mkdir my-chatgpt-app
cd my-chatgpt-app
npm init -y
Install dependencies
Install the MCP SDK and required packages:
# For TypeScript/Node.js
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
# For Python
pip install mcp fastapi uvicorn
The @modelcontextprotocol/sdk package provides tool/schema helpers, HTTP server scaffolding, and end-to-end type safety so you can focus on business logic.
Building Your MCP Server
The MCP server is the core of your app. It defines tools, handles requests, and returns data to ChatGPT. Here's a complete example in TypeScript:
// server/src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import http from "http";
// In-memory task storage
const tasks: Array<{ id: string; title: string; done: boolean }> = [];
function createTodoServer() {
const server = new McpServer({
name: "todo-app",
version: "1.0.0",
});
// Register the UI widget as a resource
server.resource(
"todo-widget",
"todo://widget",
async () => ({
contents: [{
uri: "todo://widget",
mimeType: "text/html+skybridge",
text: TODO_WIDGET_HTML, // Your HTML widget code
}],
})
);
// Define the "add_todo" tool
server.tool(
"add_todo",
"Add a new task to the todo list",
{ title: z.string().describe("The task title") },
async ({ title }) => {
const task = {
id: crypto.randomUUID(),
title,
done: false
};
tasks.push(task);
return {
content: [{ type: "text", text: `Added: ${title}` }],
structuredContent: { tasks },
_meta: {
"openai/outputTemplate": "todo://widget"
}
};
}
);
// Define the "complete_todo" tool
server.tool(
"complete_todo",
"Mark a task as complete",
{ id: z.string().describe("The task ID") },
async ({ id }) => {
const task = tasks.find(t => t.id === id);
if (task) task.done = true;
return {
content: [{ type: "text", text: `Completed task` }],
structuredContent: { tasks },
_meta: {
"openai/outputTemplate": "todo://widget"
}
};
}
);
return server;
}
// Start the HTTP server
const PORT = 8787;
const MCP_PATH = "/mcp";
const httpServer = http.createServer(async (req, res) => {
const url = new URL(req.url || "/", `http://localhost`);
// Handle CORS preflight
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "content-type, mcp-session-id",
});
res.end();
return;
}
// Handle MCP requests
if (url.pathname === MCP_PATH) {
res.setHeader("Access-Control-Allow-Origin", "*");
const server = createTodoServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
await server.connect(transport);
await transport.handleRequest(req, res);
}
});
httpServer.listen(PORT, () => {
console.log(`MCP server running at http://localhost:${PORT}${MCP_PATH}`);
});
Key concepts in this code
- McpServer: The main server class that manages tools and resources
- server.tool(): Registers a tool with name, description, schema, and handler
- server.resource(): Registers a resource (like your UI widget)
- structuredContent: Data the model can read and narrate
- _meta.openai/outputTemplate: Points to the UI widget to render
Creating the UI Widget
UI widgets render inside ChatGPT as iframes. They receive data through window.openai.toolOutput and can call tools via window.openai.callTool().
Create a file web/public/widget.html:
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
padding: 16px;
margin: 0;
}
.task {
display: flex;
align-items: center;
padding: 12px;
border: 1px solid #e5e5e5;
border-radius: 8px;
margin-bottom: 8px;
}
.task.done {
opacity: 0.5;
text-decoration: line-through;
}
.task input[type="checkbox"] {
margin-right: 12px;
}
.add-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.add-form input {
flex: 1;
padding: 8px 12px;
border: 1px solid #e5e5e5;
border-radius: 6px;
}
.add-form button {
padding: 8px 16px;
background: #000;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
</style>
</head>
<body>
<div id="app"></div>
<script>
// Get data from ChatGPT
const data = window.openai?.toolOutput || { tasks: [] };
function render() {
const app = document.getElementById('app');
const tasks = data.tasks || [];
app.innerHTML = \`
<form class="add-form" onsubmit="addTask(event)">
<input type="text" id="newTask" placeholder="Add a task...">
<button type="submit">Add</button>
</form>
<div id="tasks">
\${tasks.map(task => \`
<div class="task \${task.done ? 'done' : ''}">
<input
type="checkbox"
\${task.done ? 'checked' : ''}
onchange="toggleTask('\${task.id}')"
>
<span>\${task.title}</span>
</div>
\`).join('')}
</div>
\`;
}
async function addTask(e) {
e.preventDefault();
const input = document.getElementById('newTask');
const title = input.value.trim();
if (!title) return;
// Call the MCP tool
const result = await window.openai.callTool('add_todo', { title });
data.tasks = result.tasks;
input.value = '';
render();
}
async function toggleTask(id) {
const result = await window.openai.callTool('complete_todo', { id });
data.tasks = result.tasks;
render();
}
render();
</script>
</body>
</html>
The window.openai API
ChatGPT injects the window.openai object into your widget with these properties:
window.openai.toolOutputโ The structured content from the last tool callwindow.openai.callTool(name, args)โ Call another tool from your widgetwindow.openai.setWidgetState(state)โ Persist UI state across turnswindow.openai.localeโ User's locale for internationalization
Connecting to ChatGPT
With your server running, you need to expose it to the internet and register it in ChatGPT.
Step 1: Start your server
npm run build
node dist/index.js
# Server running at http://localhost:8787/mcp
Step 2: Create a tunnel with ngrok
ChatGPT requires HTTPS, so use ngrok to create a secure tunnel:
ngrok http 8787
# Forwarding: https://abc123.ngrok.app -> http://localhost:8787
Step 3: Add the connector in ChatGPT
- Open ChatGPT Settings โ Connectors
- Click Create (requires Developer Mode enabled)
- Enter your connector details:
- Name: Todo App
- Description: Manage your tasks
- URL:
https://abc123.ngrok.app/mcp
- Click Create
If successful, you'll see a list of tools your server exposes.
Step 4: Test your app
- Start a new chat in ChatGPT
- Click the + button and select your connector from "More"
- Ask ChatGPT: "Add a task to read a book"
- Watch your widget render with the new task!
Deploying to Production
For production, deploy your MCP server to a reliable HTTPS host. Popular options include:
- Cloudflare Workers โ Serverless, low latency, automatic TLS
- Fly.io โ Easy container deployment, global distribution
- Vercel โ Great for Node.js apps, automatic scaling
- Railway โ Simple deployment, built-in databases
- AWS Lambda / Google Cloud Run โ Enterprise-grade, scale-to-zero
Production checklist
- โ HTTPS with valid TLS certificate
- โ
CORS headers configured for
https://chatgpt.com - โ Environment variables for secrets (never commit API keys)
- โ Error handling and logging
- โ Rate limiting to prevent abuse
- โ OAuth implementation if accessing user data
Best Practices and Tips
Tool Design
- Write clear descriptions โ The model uses your tool descriptions to decide when to call them. Be specific about what each tool does and when to use it.
- Keep tools focused โ Each tool should do one thing well. Split complex operations into multiple tools.
- Return structured data โ Include
structuredContentso the model can narrate what happened.
UI Widgets
- Keep bundles small โ Minimize dependencies to reduce load time
- Handle loading states โ Show spinners while calling tools
- Use the OpenAI UI kit โ The apps-sdk-ui package provides styled components that match ChatGPT
Security
- Validate all inputs โ Use Zod schemas to validate tool parameters
- Implement OAuth โ For apps that access user data, implement proper OAuth 2.0 flows
- Audit write actions โ ChatGPT shows confirmation modals for write actions. Test these flows carefully.
Skip the complexity
Building MCP servers requires backend development, hosting, and ongoing maintenance. Agentappbuilder lets you create the same apps visually โ no code required.
Join the waitlistConclusion
You've learned how to create a ChatGPT app from scratch using the OpenAI Apps SDK and Model Context Protocol. The key steps are:
- Set up an MCP server with tools and resources
- Create a UI widget that reads
window.openai.toolOutput - Expose your server via HTTPS
- Register it in ChatGPT's connector settings
- Deploy to production with proper security
While building from scratch gives you full control, it requires significant development effort. For teams without backend expertise or those who want to ship faster, no-code platforms like Agentappbuilder can dramatically reduce time to market.