# 3. Build walkthrough — zero to working app
Part 3 of 6. ← Architecture · Index · Next → Guardrails
We'll build a minimal "Todo" app — the shape of the official quickstart — so you have a working baseline before adding your own tools.
3.1 Prerequisites
- OpenAI Platform account with Owner role or the
api.apps.writepermission. - Verified org identity in the Platform Dashboard (required for submission).
- Node.js 18+ or Python 3.10+.
- A public HTTPS endpoint. Local:
ngrokor Cloudflare Tunnel. Prod: anywhere you can run a long-lived HTTPS server (Vercel Functions, Fly, Render, VPS). - ChatGPT account with Developer Mode enabled (Settings → Apps & Connectors → Advanced).
- Chrome 142 gotcha: disable
chrome://flags/#local-network-access-checkfor local widget testing, or the widget will not render.
Framework choices: React for the widget (bundled via esbuild or Vite). Server: @modelcontextprotocol/sdk (TS) or mcp/FastMCP (Python).
3.2 Scaffold
``bash
mkdir chatgpt-todo-app && cd chatgpt-todo-app
npm init -y
npm pkg set type="module"
npm install @modelcontextprotocol/sdk@^1.20 @modelcontextprotocol/ext-apps@^1.0 zod
mkdir public
``
Reference clone for copy-paste-worthy starters:
``bash
git clone https://github.com/openai/openai-apps-sdk-examples.git
``
Contains: mcp_app_basics_node, kitchen_sink_server_node/python, pizzaz_server_node/python (list/carousel/map views), shopping_cart_python, solar-system_server_python, authenticated_server_python.
3.3 Define tools (MCP server)
server.js:
```js import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps"; import { z } from "zod"; import http from "node:http"; import fs from "node:fs/promises"; import { randomUUID } from "node:crypto";
const todoHtml = await fs.readFile("./public/todo-widget.html", "utf8"); const todos = new Map(); // demo-only in-memory store
const server = new McpServer( { name: "todo-app", version: "0.1.0" }, { capabilities: { tools: {}, resources: {} } } );
// 1) Register the UI template as an MCP resource registerAppResource( server, "todo-widget", "ui://widget/todo.html", { _meta: { ui: { prefersBorder: true, domain: "todo.example.com", // required at submission; your dedicated origin csp: { connectDomains: ["https://todo.example.com"], resourceDomains: ["https://todo.example.com"], frameDomains: [], }, }, "openai/widgetDescription": "An interactive todo list.", }, }, async () => ({ contents: [{ uri: "ui://widget/todo.html", mimeType: RESOURCE_MIME_TYPE, text: todoHtml }], }) );
// 2) add_todo tool
registerAppTool(
server,
"add_todo",
{
title: "Add todo",
description: "Creates a todo item with the given title.",
inputSchema: z.object({ title: z.string().min(1).max(200) }),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
_meta: {
ui: { resourceUri: "ui://widget/todo.html", visibility: ["model", "ui"] },
"openai/toolInvocation/invoking": "Adding todo…",
"openai/toolInvocation/invoked": "Todo added",
},
},
async ({ title }) => {
const id = randomUUID();
todos.set(id, { id, title, done: false });
return {
content: [{ type: "text", text: Added: ${title} }],
structuredContent: { todos: [...todos.values()] },
};
}
);
// 3) complete_todo tool
registerAppTool(
server,
"complete_todo",
{
title: "Complete todo",
description: "Marks a todo item as completed by id.",
inputSchema: z.object({ id: z.string().uuid() }),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
_meta: { ui: { resourceUri: "ui://widget/todo.html" } },
},
async ({ id }) => {
const t = todos.get(id);
if (!t) throw new Error("not_found");
t.done = true;
return {
content: [{ type: "text", text: Completed ${t.title} }],
structuredContent: { todos: [...todos.values()] },
};
}
);
// 4) HTTP transport const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport);
const httpServer = http.createServer(async (req, res) => { if (req.method === "OPTIONS") { res.writeHead(204, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS", "Access-Control-Allow-Headers": "Content-Type,Authorization,MCP-Session-Id", }); return res.end(); } if (req.url === "/") { res.writeHead(200); return res.end("ok"); } if (req.url?.startsWith("/mcp")) return transport.handleRequest(req, res); res.writeHead(404); res.end(); });
httpServer.listen(8787, () => console.log("Todo MCP server on http://localhost:8787/mcp")); ```
Key ideas:
structuredContent.todosis what the model reads on the next turn._meta.ui.resourceUripoints the client at the widget bundle.- Annotation hints (
readOnlyHint,destructiveHint,openWorldHint,idempotentHint) drive ChatGPT's safety UX. Wrong values are a top cause of review rejection. See guardrails.
3.4 Build the widget
The simplest shape is a self-contained HTML file. Save as public/todo-widget.html:
``html
<!doctype html>
<html><head><meta charset="utf-8"><style>
body{font:14px system-ui;padding:12px;color:var(--text)}
li{display:flex;gap:8px;align-items:center}
.done{text-decoration:line-through;opacity:.6}
</style></head><body>
<form id="f"><input id="t" placeholder="New task" required><button>Add</button></form>
<ul id="list"></ul>
<script type="module">
function render(){
const out = window.openai?.toolOutput ?? { todos: [] };
const ul = document.getElementById("list");
ul.innerHTML = "";
for (const t of out.todos ?? []) {
const li = document.createElement("li");
li.className = t.done ? "done" : "";
li.innerHTML = <input type="checkbox" ${t.done?"checked":""} data-id="${t.id}"> <span>${t.title}</span>;
ul.appendChild(li);
}
}
document.addEventListener("openai:set_globals", render);
document.getElementById("f").addEventListener("submit", async (e) => {
e.preventDefault();
const title = document.getElementById("t").value;
document.getElementById("t").value = "";
await window.openai.callTool("add_todo", { title });
});
document.getElementById("list").addEventListener("change", async (e) => {
if (e.target.matches('input[type=checkbox]'))
await window.openai.callTool("complete_todo", { id: e.target.dataset.id });
});
render();
</script></body></html>
``
For production, bundle a real React component tree with esbuild or Vite (see kitchen_sink_server_node in the examples repo) and inline the ESM output into a single HTML resource. The optional `apps-sdk-ui` component library matches ChatGPT's look and feel.
UI constraints
- Sandboxed iframe with strict CSP. Subframes blocked unless you add
_meta.ui.csp.frameDomains(invites stricter review). - Forbidden browser APIs:
window.alert,window.prompt,window.confirm,navigator.clipboard. Usewindow.openai.requestModal(...)instead. - File downloads: use
window.openai.getFileDownloadUrl({ fileId }). Don't embed raw blobs. - Display modes:
window.openai.requestDisplayMode("inline" | "pip" | "fullscreen"). - Persist UI state with
window.openai.setWidgetState(state)— never put secrets or tokens in widget state.
3.5 Handle auth
For anything touching user accounts, implement OAuth 2.1 with PKCE and Dynamic Client Registration (DCR). ChatGPT self-registers as a client, so you don't hand out credentials manually.
- Expose
/.well-known/oauth-protected-resourceand (on your auth server)/.well-known/oauth-authorization-serverwith aregistration_endpoint. - On every
tools/call, validate theAuthorization: Bearer <jwt>header: issuer, audience, expiry, scopes. - On failure, return HTTP 401 with a `WWW-Authenticate` challenge — ChatGPT will re-run the flow.
- Mark each tool's required scopes via
securitySchemesin its descriptor. Mixtype: oauth2tools withtype: noauthtools to allow a "logged-out preview." - Canonical example:
authenticated_server_pythonin the OpenAI examples repo.
Guardrail — never leak tokens to the iframe. Perform privileged work in the MCP tool handler using the bearer token ChatGPT attaches. The widget should receive results, not credentials. widgetState is UI-only.3.6 Connect to ChatGPT for local testing
1. node server.js — confirm Todo MCP server on http://localhost:8787/mcp.
2. Validate with MCP Inspector (catches 80% of schema mistakes):
``bash
npx @modelcontextprotocol/inspector@latest --server-url http://localhost:8787/mcp --transport http
`
3. **Expose with ngrok:**
`bash
ngrok http 8787
`
4. In ChatGPT: Settings → Apps & Connectors → Advanced settings → toggle Developer Mode.
5. Settings → Connectors → **Create** → paste https://<subdomain>.ngrok.app/mcp`, give it a name + description.
6. Open a new chat, pick your connector from the More menu, and prompt: "Add 'read my book' to my todo list."
3.7 Submit for review / publish
- In the OpenAI Platform Dashboard, complete organisation verification and add customer-support contact details.
- Create an app draft, fill in privacy policy URL, category, supported locales (start with
en), demo credentials (fully-featured, no MFA, no mandatory sign-up wall), and screenshots. - Point the draft at your production MCP server URL (HTTPS, stable, low-latency).
- Submit. Status updates via email. OpenAI has not published a public review SLA — plan for days, not hours.
See Testing & publishing for the full pre-submission checklist.