Zuplo logo
Back to all articles
December 5, 2025
18 min read

Build Apps for ChatGPT with OpenAI Apps SDK and Zuplo

Martyn Davies
Martyn DaviesDeveloper Advocate

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 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

Watch the Demo#

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.

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 that calls the GitHub API, processes the response, and returns structured JSON:

// 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:

// 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:

{
  "/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").

{
  "/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.

Step 5: Configure the MCP Server#

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

{
  "/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

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

For further information on Zuplo MCP Server support, and how to work with the OpenAI Apps SDK, see our documentation.

Questions? Let's chat

Join our community to discuss API integration and get help from our team and other developers.

OPEN DISCORD
51members online