---
title: "Build Apps for ChatGPT with OpenAI Apps SDK and Zuplo"
description: "Zuplo now supports the OpenAI Apps SDK for building interactive widgets inside ChatGPT using an MCP server. See how to create MCP resources, configure the required metadata, and render custom apps directly in the chat interface."
canonicalUrl: "https://zuplo.com/blog/2025/12/05/openai-apps-sdk-zuplo"
pageType: "blog"
date: "2025-12-05"
authors: "martyn"
tags: "Model Context Protocol"
image: "https://zuplo.com/og?text=Build%20Apps%20for%20ChatGPT%20with%20OpenAI%20Apps%20SDK%20and%20Zuplo"
---
OpenAI recently released their Apps SDK, which lets you build interactive
applications that render directly inside ChatGPT. These apps go beyond simple
text responses. They can display custom visualizations, charts, forms, and
interactive widgets, all powered by your MCP server.

Zuplo now supports the
[Apps SDK](https://zuplo.com/docs/mcp-server/openai-apps-sdk) in our dynamic MCP
servers.

## What Are OpenAI Apps?

OpenAI Apps are HTML-based widgets that load inside ChatGPT. When a user invokes
an MCP tool, the server can return not just data but also a template for
displaying that data. ChatGPT injects the data into the template and renders it
as an interactive widget.

This opens up possibilities that plain text responses can't match: data
visualizations, interactive forms, custom dashboards, and rich media
experiences, all within the chat interface.

In this post, I'll walk through building a GitHub stats widget that fetches user
data and renders it as a visual dashboard inside ChatGPT.

![The final app in use in ChatGPT](/media/posts/2025-12-05-openai-apps-sdk-zuplo/openai-app-zuplo.png)

## Watch the Demo

<YouTubeVideo videoId="5hcmMDyKR88" />

## How It Works

The architecture involves three components:

1. **An API endpoint** that fetches and processes data (in our case, GitHub
   stats)
2. **A widget resource** that defines the HTML/CSS/JavaScript template
3. **An MCP server** that exposes both as tools and resources to ChatGPT

When a user asks for GitHub stats, the MCP tool returns the data along with
metadata pointing to the widget resource. ChatGPT fetches the template, injects
the data, and renders the result.

## Building the GitHub Stats Widget

Let's walk through the implementation in Zuplo. Below we show the code based
approach but the majority of the work needed here can be achieved using the UI
in the Zuplo portal.

To see that in action, check out the
[video walkthrough](https://youtu.be/5hcmMDyKR88).

Happy with code? Read on!

### Step 1: The API Endpoint

First, we need an endpoint that fetches GitHub data. This is a standard Zuplo
route assigned to use a
[custom request handler](https://zuplo.com/docs/handlers/custom-handler) that
calls the GitHub API, processes the response, and returns structured JSON:

```typescript
// In Zuplo this would be a custom request handler, modules/github-stats.ts
export default async function (request: ZuploRequest, context: ZuploContext) {
  const { username } = await request.json();

  // Fetch user data from GitHub API
  const userResp = await fetch(`https://api.github.com/users/${username}`);
  const user = await userResp.json();

  // Fetch repositories
  const reposResp = await fetch(
    `https://api.github.com/users/${username}/repos?sort=stars&per_page=10`,
  );
  const repos = await reposResp.json();

  // Process and return structured data
  return {
    username: user.login,
    avatar: user.avatar_url,
    followers: user.followers,
    public_repos: user.public_repos,
    top_repos: repos.map((r) => ({
      name: r.name,
      stars: r.stargazers_count,
      language: r.language,
    })),
  };
}
```

### Step 2: The Widget Resource

Most app widgets in ChatGPT are made up of HTML with embedded CSS and
JavaScript. It then uses template placeholders that ChatGPT will populate with
the data from our API.

In Zuplo, these resources need to be created as a module that exports the
template you want to use:

```typescript
// In Zuplo this would be modules/widget-resource.ts
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";

const GitHubStatsWidgetHTML = `
<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: system-ui, sans-serif; padding: 20px; }
    .stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
    .stat-card { background: #f6f8fa; padding: 16px; border-radius: 8px; }
    /* ... additional styles ... */
  </style>
</head>
<body>
  <div class="github-stats">
    <h2>GitHub Stats for ${data.username}</h2>
    <div class="stats-grid">
      <div class="stat-card">
        <div class="stat-value">${data.followers}</div>
        <div class="stat-label">Followers</div>
      </div>
      <!-- ... additional stats ... -->
    </div>
  </div>
</body>
</html>
`;

export default async function (request: ZuploRequest, context: ZuploContext) {
  return new Response(GitHubStatsWidgetHTML, {
    headers: {
      "Content-Type": "text/html+skybridge",
    },
  });
}
```

The critical part here is the `Content-Type` header. OpenAI requires
`text/html+skybridge` for app widgets. Without this, ChatGPT won't render the
template.

### Step 3: Configure the Resource Route

Add a route for the widget resource in your Zuplo gateway:

```json
{
  "/mcp/resources/widget/github-stats": {
    "get": {
      "operationId": "githubStatsWidgetResource",
      "summary": "GitHub Stats Widget UI",
      "x-zuplo-route": {
        "handler": {
          "export": "default",
          "module": "$import(./modules/widget-resource)"
        },
        "mcp": {
          "type": "resource",
          "name": "GitHub Stats Widget",
          "description": "Visualization widget for GitHub statistics",
          "uri": "resource://widget/github-stats",
          "mimeType": "text/html+skybridge"
        }
      }
    }
  }
}
```

Setting the `mcp.type` to `"resource"` (rather than `"tool"`) tells Zuplo to
expose this as an MCP resource that ChatGPT can fetch and render.

### Step 4: Add OpenAI Metadata

For ChatGPT to render widgets correctly, the tool needs additional metadata in
the `_meta` key. This tells ChatGPT what to display while loading, as well as
what template to use.

You can see below how the template references the widget we created:
`"openai/outputTemplate": "resource://widget/github-stats",`. This is the URI
that was assigned to the widget in Zuplo
(`uri": "resource://widget/github-stats"`).

```json
{
  "/github-stats": {
    "post": {
      "operationId": "getGitHubStats",
      "x-zuplo-route": {
        "handler": {
          "export": "default",
          "module": "$import(./modules/github-stats)"
        },
        "mcp": {
          "type": "tool",
          "_meta": {
            "openai/outputTemplate": "resource://widget/github-stats",
            "openai/widgetAccessible": true,
            "openai/toolInvocation/invoking": "Fetching GitHub stats...",
            "openai/toolInvocation/invoked": "GitHub stats loaded"
          }
        }
      }
    }
  }
}
```

The `_meta` fields control the ChatGPT UI experience, and there are many more
options you could include here depending on the experience you want to create.
There's a full list available in the
[OpenAI Apps SDK documentation](https://developers.openai.com/apps-sdk).

### Step 5: Configure the MCP Server

Finally, add both the tool and resource to your MCP server:

```json
{
  "/mcp": {
    "post": {
      "operationId": "mcpServer",
      "x-zuplo-route": {
        "handler": {
          "export": "mcpServerHandler",
          "module": "$import(@zuplo/runtime)",
          "options": {
            "name": "GitHub Stats",
            "version": "0.0.1",
            "operations": [
              { "file": "./config/routes.oas.json", "id": "getGitHubStats" },
              {
                "file": "./config/routes.oas.json",
                "id": "githubStatsWidgetResource"
              }
            ]
          }
        }
      }
    }
  }
}
```

## Connecting to ChatGPT

With your MCP server now set up in Zuplo, it's time to add it to ChatGPT (note
that you will need to turn on Developer Mode in order to do this next step):

1. Open **ChatGPT Settings > Apps and Connectors**
2. Click "Create" and enter your MCP server URL
3. ChatGPT will fetch the server metadata and display available tools

![The settings for the app in ChatGPT](/media/posts/2025-12-05-openai-apps-sdk-zuplo/openai-app-settings.png)

Once connected, you can ask ChatGPT something like "Get me the GitHub stats for
zuplo" and it will invoke the tool, fetch the data, and render your custom
widget.

Any changes you make in Zuplo are reflected immediately after redeploying. You
don't need to re-add the connector in ChatGPT. Just click "Refresh" in the
settings for the app to pull the latest configuration.

## Beyond Simple Widgets

The example here is straightforward, but the Apps SDK supports much more:

- **Interactive widgets**: Your JavaScript can respond to user input and send
  data back to the conversation
- **Dynamic updates**: Widgets can update based on new prompts or tool calls
- **Complex visualizations**: Charts, graphs, maps, and any other HTML content
  you can build

The key is that your MCP server controls both the data and the presentation. You
decide what gets rendered and how.

## Try It Yourself

We've published a complete working example that you can deploy to Zuplo in one
click:

[OpenAI Apps Example](https://zuplo.com/examples)

For further information on Zuplo MCP Server support, and how to work with the
OpenAI Apps SDK, see our
[documentation](https://zuplo.com/docs/mcp-server/introduction).