Skip to content
robinSenior Software Engineer
All articlesBuilding One of the First ChatGPT Apps

Building One of the First ChatGPT Apps

15 Nov 2025 · 5 min read · 836 words

I spent a few weeks building one of the first ChatGPT Apps with OpenAI’s Apps SDK and the Model Context Protocol. The result was a production MCP server, an iframe UI, and a familiar systems problem: make a web application fast, safe, and predictable when the client is a model.

1759777810867.jpeg

The app lets ChatGPT search classes, fetch details, build training plans, and schedule workouts. The server exposes tools and widget resources. ChatGPT calls a tool, receives structured data and a UI resource, then renders the widget inside a sandboxed iframe.

Loading diagram...

Keep the MCP layer boring

I built the server as a Next.js app on Vercel with a thin layer over the official SDK. That layer has three jobs:

  • Implement the MCP protocol.
  • Register tools.
  • Serve widget HTML.

The protocol layer routes JSON-RPC requests and resources such as ui://widget/search.html. It does not know anything about workouts or classes. App-specific logic stays in tools and helpers.

Each tool carries metadata describing whether it is read-only, destructive, or open-world, along with the security scheme it needs. Keeping that metadata near the handler makes the risk surface visible before a tool touches account state.

Search a constrained catalogue

The hardest part was not wiring up MCP. It was putting a search API in front of a rights-constrained catalogue and exposing only the classes we were allowed to show externally.

Users ask for things such as “20-minute Cody ride”, “90s rock run”, or “beginner yoga from last year”. The search service needs enough metadata to answer those queries without loading a full database into every serverless function.

I built a discipline-sharded index behind Vercel Blob storage and the edge cache. There are 12 shards, one for each fitness discipline. Each shard contains compressed JSON with instructor names, music artists, duration, difficulty, air date, and lightweight category tags.

The query path is deliberately ordinary:

  • Parse instructor, artist, duration, difficulty, year, and free-text terms.
  • Load the relevant shard, or fan out to all 12 when the query is ambiguous.
  • Apply discipline, duration, difficulty, and year as hard filters.
  • Rank the remaining classes using structured metadata, with instructors and artists weighted above title and description.
  • Deduplicate results and return only the requested limit.

Word-boundary matching matters here. A loose substring search can improve a benchmark while producing worse results in a conversation. The index is precomputed, the shards are cached at the edge, and the search path is mostly CPU work over in-memory data.

From a distance, this looks like an AI search feature. Up close, it is classic information retrieval tuned for a chat interface.

Serve static widgets inside ChatGPT

The widget runs in ChatGPT’s iframe. It loads once, then updates as the model calls tools and streams data into it.

I started with Next.js and server-rendered components. Each server render and hydration cycle added 100 to 200 ms on top of tool latency and model thinking time. That work did not improve the user’s result.

I switched to static HTML templates with a small Preact runtime. At build time, a script compiles Tailwind, bundles Preact, and emits widget HTML files. At runtime, the MCP server serves those files from memory. The browser handles the updates.

The interaction is simple:

  1. Load a small Preact app into the iframe.
  2. Let the MCP tool return structured content and a resource reference.
  3. Hydrate the widget with the tool output that ChatGPT injects into the page.

The Apps SDK UI documentation describes the bridge between the host and the iframe. Once the shell is loaded, each interaction pays MCP server time plus browser rendering. Model reasoning still dominates the request, so the server should not add work around it.

Cache the expensive data

Vercel serverless requests can land in different containers. I used the Next.js data cache, Vercel’s edge CDN, and a small key-value store for shared data.

The main cache layers are:

  • Sitemap and class metadata, cached for 24 hours.
  • Class details, cached for a shorter period at the edge and in serverless containers.
  • Short-lived in-memory caches for shard manifests and search indexes.

Every tool that needs class data reuses the same sitemap fetch and builds its own lookup on top. That keeps search, class details, training plans, and scheduling aligned when classes change.

Search is also bounded by design. Fetch the sitemap in parallel with search, load each shard once per invocation, reuse it across queries, and stop scoring after enough high-quality results are available. Those changes reduce CPU work without involving the model.

The operating model

An AI app adds model-mediated tool calls and an embedded client. It does not remove the usual system obligations: define safe boundaries, keep data current, measure latency, and avoid repeated work.

The execution path is longer than a normal web request:

Loading diagram...

That makes the boundaries more visible. Keep the MCP layer simple, make retrieval deterministic, treat the iframe as a performance-critical client, and cache the data that several tools share.

Building with a model does not change the fundamentals. It makes failures between those fundamentals easier to see.