
TL;DR
Apps SDK extends MCP with UI. Here is how to ship a real Apps SDK app from scratch: logic, interface, deploy, distribution, and the gotchas that cost me a weekend.
Read next
OpenAI shipped a new feature in the ChatGPT macOS app that lets it read context from VS Code, Xcode, Terminal, and iTerm2. Here is how to set it up, what it can actually do today, and why the future of this feature matters more than the current version.
8 min readOpenAI added scheduled tasks and reminders to ChatGPT, turning it from a chat interface into something closer to a personal AI agent. Here is how it works, what it can do today, and where this is heading.
8 min readOpenAI has merged its browsing capabilities with deep research into a single agent that can take action on the web, generate spreadsheets and slide decks, and handle complex multi-step tasks from sta...
7 min readApps SDK is OpenAI's answer to the question "what does a third-party app inside ChatGPT actually look like." The technical answer is MCP plus a UI runtime that renders inline in the ChatGPT surface. The practical answer is that you can now ship something that feels like a native ChatGPT feature, addressed via natural language, with a real interface, distributed through OpenAI's directory.
I shipped a real Apps SDK app the week the SDK opened up. It is a "weekly DevDigest brief" app that pulls the most-read posts from the DevDigest backend and renders them as an interactive card in ChatGPT. Building it took about a day of focused work. Most of that day was spent on three gotchas that the docs do not cover well: auth, distribution, and telemetry. This post walks through the full build with an emphasis on those three.
The architecture has two pieces. One is an MCP server you host. The other is a UI runtime that ChatGPT renders on your behalf when the user invokes your app.
For the broader MCP map, pair this with What Is MCP (Model Context Protocol)? A TypeScript Developer's Guide and The Complete Guide to MCP Servers; those pieces cover the concepts and server-selection layer behind this article.
The MCP server is a standard MCP server. If you have built one before, you already know most of the moves: a list of tools, a list of resources, request handlers that return structured data. The thing that is new is that some of your tool responses can include UI directives. ChatGPT picks up those directives and renders an interactive component inline in the chat surface, parameterized by the data your tool returned.
The UI runtime is constrained on purpose. You do not get arbitrary HTML. You get a component vocabulary defined by Apps SDK: cards, lists, buttons, forms, simple charts, and a handful of layout primitives. The constraint is the point. Apps render consistently across ChatGPT clients without you shipping a webview, and OpenAI can ensure the UI cannot do anything unsafe inside the ChatGPT surface.
The mental model that worked for me is this. MCP tools are how your app does things. UI directives are how your app shows things. The user's natural-language prompt is how your app gets invoked. Distribution is how your app gets discovered.
Here is the file structure I landed on after one round of refactoring.
weekly-brief-app/
package.json
tsconfig.json
src/
server.ts # MCP server entrypoint
tools/
get_brief.ts # Tool that returns the brief data
subscribe.ts # Tool that toggles user subscription state
ui/
brief_card.ts # UI directive for the brief
subscribe_form.ts
state/
store.ts # Per-user state (KV in production)
auth/
session.ts # User identity + token verification
manifest.json # Apps SDK manifest for distribution
The split that matters is tools/ for logic and ui/ for the directive payloads. Keeping them separate means you can unit-test the data path without spinning up the UI runtime, and you can iterate on UI without changing tool signatures. The first iteration of the app had logic and UI in the same file and got messy fast.
The MCP server entry is short.
import { McpServer } from "@modelcontextprotocol/sdk/server";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/transport/stdio";
import { getBrief } from "./tools/get_brief.js";
import { subscribe } from "./tools/subscribe.js";
const server = new McpServer({
name: "weekly-brief",
version: "0.1.0",
});
server.tool(getBrief);
server.tool(subscribe);
const transport = new StdioServerTransport();
await server.connect(transport);
The tool handler is where the UI directive comes in.
import { defineTool } from "@modelcontextprotocol/sdk/server";
import { briefCard } from "../ui/brief_card.js";
import { fetchBriefForUser } from "../data.js";
export const getBrief = defineTool({
name: "get_weekly_brief",
description: "Fetch this week's DevDigest brief for the current user. Returns the top posts and lets the user open or save them inline.",
inputSchema: {
type: "object",
properties: {
week: { type: "string", description: "ISO week, e.g. 2026-W17" },
},
},
async handler({ week }, ctx) {
const userId = ctx.user.id;
const data = await fetchBriefForUser(userId, week);
return {
content: [{ type: "text", text: `Brief for ${week}` }],
ui: briefCard(data),
};
},
});
The ui field on the tool response is what ChatGPT renders. The text content is the fallback for clients that do not render UI. Both are required. Skip the text content and your app breaks the moment a user invokes it from a client that does not support the runtime.
Get the weekly deep dive
Tutorials on Claude Code, AI agents, and dev tools - delivered free every week.
From the archive
Apr 29, 2026 • 12 min read
Apr 29, 2026 • 10 min read
Apr 29, 2026 • 11 min read
Apr 29, 2026 • 9 min read
The brief app does three things: fetch the week's top posts for the signed-in user, let the user click through to read them, and let the user toggle whether they want the brief delivered to their email inbox each week.
The brief card directive looks like this.
import type { UiDirective } from "@openai/apps-sdk";
export function briefCard(data: BriefData): UiDirective {
return {
type: "card",
title: `DevDigest brief for ${data.week}`,
body: [
{
type: "list",
items: data.posts.map((p) => ({
title: p.title,
subtitle: p.excerpt,
actions: [
{ type: "open_url", label: "Read", url: p.url },
{ type: "tool_call", label: "Save", tool: "save_post", args: { id: p.id } },
],
})),
},
{
type: "button",
label: data.subscribed ? "Unsubscribe" : "Subscribe to weekly email",
action: { type: "tool_call", tool: "subscribe", args: { state: !data.subscribed } },
},
],
};
}
The card-with-list shape is the workhorse of Apps SDK UIs. Most apps end up rendering some variant of this. The actions array is the part that makes it feel interactive. open_url opens a link in a new surface. tool_call re-invokes one of your MCP tools, optionally with a fresh UI directive in response. That round trip is how you build interactive flows without writing client-side code.
Auth is the part that bit me hardest. The Apps SDK runtime gives your tool handler a ctx.user object, but the identity in that object is scoped to ChatGPT, not your app. To map a ChatGPT user to a user in your own product, you need to do an explicit linking flow the first time the user invokes your app.
The pattern that worked is a deferred-link tool. The first invocation of the brief tool checks whether the ChatGPT user ID is linked to a DevDigest account. If not, it returns a UI directive with a one-time-code link button that opens DevDigest's login flow with a link_token query param. The DevDigest backend completes the link by associating the ChatGPT user ID with the DevDigest account and reporting back via webhook to the Apps SDK runtime.
async function ensureLinked(ctx: ToolContext): Promise<UserLink | null> {
const link = await store.getLink(ctx.user.id);
if (link) return link;
const linkToken = await store.createLinkToken(ctx.user.id);
return null; // caller renders link UI
}
export const getBrief = defineTool({
// ...
async handler(args, ctx) {
const link = await ensureLinked(ctx);
if (!link) {
return {
content: [{ type: "text", text: "Link your DevDigest account to continue." }],
ui: linkPromptCard(linkToken),
};
}
// ... fetch brief
},
});
State is simpler. Apps SDK gives you no built-in storage, which is the right call. Use whatever KV or database you already have. I keep the link table and the per-user subscription state in the existing DevDigest Postgres instance and access it through the same data layer the rest of the product uses. The MCP server is a thin shell over our existing API.
For teams that do not have an existing backend, MCPaaS gives you a hosted MCP runtime with a bundled KV store and the auth-link plumbing already wired up. It pairs nicely with Apps SDK clients because the deployment story is "push your tool handlers and you are done."
Distribution is the next big surprise. You do not just ship an Apps SDK app and have it appear in ChatGPT. You publish to the OpenAI directory, your app gets reviewed, and then it shows up in search.
The manifest is the artifact that drives discovery.
{
"name": "DevDigest Weekly Brief",
"slug": "devdigest-brief",
"description": "Get your personalized weekly DevDigest brief inside ChatGPT.",
"icon": "https://cdn.devdigest.com/apps/brief-icon.png",
"tools": ["get_weekly_brief", "save_post", "subscribe"],
"discovery": {
"keywords": ["devdigest", "weekly brief", "developer news"],
"example_prompts": [
"What's in this week's DevDigest brief?",
"Show me the latest from DevDigest"
]
},
"auth": {
"type": "linked_account",
"link_url": "https://devdigest.com/apps-sdk/link"
}
}
Two non-obvious notes. First, example_prompts is what ChatGPT uses to learn when to suggest your app. Generic prompts get drowned out. Specific, brand-anchored prompts work much better. Second, the icon URL has to be served with permissive CORS or the directory listing breaks silently.
If you have shipped MCP servers before, you know that discoverability is the whole game. I learned this running MCP Directory, which now indexes more than 200 servers. The same lesson applies on Apps SDK: a good description, specific example prompts, and a clean icon move the needle more than features do.
The last piece is figuring out what users actually do. Apps SDK does not give you UI-level telemetry out of the box. You have to instrument it yourself by logging tool invocations, including tool calls triggered from UI buttons, into your own analytics.
I wrap every tool handler in a small middleware that emits a structured event before and after.
function withTelemetry<T extends Tool>(tool: T): T {
const original = tool.handler;
tool.handler = async (args, ctx) => {
const start = Date.now();
await analytics.track({
event: "apps_sdk.tool_invoked",
tool: tool.name,
user_id: ctx.user.id,
args,
});
try {
const result = await original(args, ctx);
await analytics.track({
event: "apps_sdk.tool_completed",
tool: tool.name,
latency_ms: Date.now() - start,
ui_rendered: !!result.ui,
});
return result;
} catch (err) {
await analytics.track({
event: "apps_sdk.tool_failed",
tool: tool.name,
error: String(err),
});
throw err;
}
};
return tool;
}
The event I look at most is the funnel from "first invocation" to "linked account" to "second invocation." That funnel tells me whether the auth-link flow is working in practice. The first version of the app had a 38 percent drop-off on the link step, which I traced to the link button copy being unclear. Rewriting the button label from "Link account" to "Connect DevDigest" lifted completion to 71 percent.
I shipped the full build walkthrough on the DevDigest YouTube channel, with the file-tree screenshot and the deployed app demo. The repo is private until I clean up the auth-link flow for general consumption. If you are building your first Apps SDK app, the order of operations I would recommend is: get a single tool returning a card, ship it locally, then layer auth, then telemetry, then distribution. Trying to do all four at once is how the gotchas compound.
Technical content at the intersection of AI and development. Building with AI agents, Claude Code, and modern dev tools - then showing you exactly how it works.
OpenAI's cloud coding agent. Runs in a sandboxed container, reads your repo, executes tasks, and submits PRs. Uses GPT-5...
View ToolThe TypeScript toolkit for building AI apps. Unified API across OpenAI, Anthropic, Google. Streaming, tool calling, stru...
View ToolOpenAI's flagship. GPT-4o for general use, o3 for reasoning, Codex for coding. 300M+ weekly users. Tasks, agents, web br...
View ToolOpenAI's latest flagship model. Major leap in reasoning, coding, and instruction following over GPT-4o. Powers ChatGPT P...
View ToolStep-by-step guide to building an MCP server in TypeScript - from project setup to tool definitions, resource handling, testing, and deployment.
AI AgentsConfigure Claude Code for maximum productivity -- CLAUDE.md, sub-agents, MCP servers, and autonomous workflows.
AI AgentsWhat MCP servers are, how they work, and how to build your own in 5 minutes.
AI Agents
OpenAI has entered the browser wars with ChatGPT Atlas, a web browser that embeds ChatGPT directly into the browsing exp...

OpenAI has merged its browsing capabilities with deep research into a single agent that can take action on the web, gene...

OpenAI's Deep Research is an AI agent inside ChatGPT that plans and executes multi-step research workflows, browsing doz...

OpenAI added scheduled tasks and reminders to ChatGPT, turning it from a chat interface into something closer to a perso...

OpenAI shipped a new feature in the ChatGPT macOS app that lets it read context from VS Code, Xcode, Terminal, and iTerm...

The Realtime API uses WebSockets for two-way voice interaction with function calling and stateful conversations. Here is...

New tutorials, open-source projects, and deep dives on coding agents - delivered weekly.