DEV Community

Riley Draward for Gadget

Posted on

Tutorial: Build a ChatGPT app using OpenAI's Apps SDK and Gadget

The ChatGPT Apps SDK gives developers the ability to make ChatGPT more interactive. It lets it call APIs, store user data, and render custom UIs.

In this tutorial, you'll build a movie app that connects to The Movie Database (TMDB), fetches data on popular and upcoming movies, and lets users create their own watch list inside ChatGPT.

You’ll learn how to:

  • Build and register tools using the Model Context Protocol (MCP)
  • Create widgets that render React components inside ChatGPT
  • Make API calls to your backend from within a ChatGPT app

A paid ChatGPT plan is required to build apps, and developer mode must be enabled.

You’ll also need an account with The Movie Database in order to generate an API key.

How ChatGPT apps work

ChatGPT apps run inside ChatGPT in an iframe environment. They’re built with the OpenAI Apps SDK and communicate with ChatGPT using MCP, a protocol for defining tools (server-side functions ChatGPT can call) and resources (UI widgets).

Each app defines:

  • Iframe sandboxing: Each app runs inside a triple-layered iframe sandbox, isolating your UI from ChatGPT and protecting user data.
  • MCP as the bridge: MCP defines your app’s tools and resources so ChatGPT knows what functions it can call and what widgets to render as output.
  • Tool calls: When a user asks something your app can handle, ChatGPT invokes a registered tool on your MCP server with structured JSON input.
  • Resources and widgets: Tools return structured content and point to a resource (like a React component) that ChatGPT renders inline inside the chat.
  • window.openai environment: Inside that iframe, your widget can read tool output, persist state, send follow-up messages, or make authenticated API calls.

This project uses Gadget to handle OAuth, database models, MCP setup, and so you can focus on building the app itself.

1. Create a new project

Start by forking the Gadget ChatGPT app template. Give your project a name, e.g. chatgpt-movies. This template includes:

  • Built-in OAuth
  • A pre-configured MCP server (api/mcp.ts)
  • A React-based widgets folder (web/chatgpt-widgets)
  • A Vite frontend setup using vite-plugin-chatgpt-widgets

When you build and run your app in Gadget, you’ll have both:

  • Unlimited development environments (e.g. myapp--development.gadget.app)
  • A production environment (deployed later)

Gadget apps come with all the production-grade infrastructure needed to build and run ChatGPT apps. Every Gadget app is powered by a Postgres database with multiple read replicas, a Node + Fastify backend, and a React frontend served and bundled by Vite, and also includes Elasticsearch built into the auto-generated APIs and a built-in background jobs system. Everything is stitched together so you can focus on writing the code to make your unique ChatGPT app.

Understanding the MCP server

Your MCP server is what exposes tool calls and frontend widgets to ChatGPT.

In the template, it’s defined in api/mcp.ts and is created and called using the pre-defined /mcp HTTP routes (api/routes/mcp/*). A new MCP server instance is created and all React widgets (from the chatgpt-widgets folder) are registered as resources.

Each resource gets a URI (e.g. ui://widget/HelloWorld.html), and tools reference these URIs in their output templates meta definition:

_meta: {
  "openai/outputTemplate": "ui://widget/HelloWorld.html",
}
Enter fullscreen mode Exit fullscreen mode

This is how a tool call knows which widget to render its data in.

The __getGadgetAuthTokenV1 tool included by default lets widgets make authenticated API requests directly using your Gadget app’s API client (web/api.ts) so you don’t have to define a new tool call for every single request you want to make from your widget.

2. Connecting your app to ChatGPT

Now you can install your app in ChatGPT by setting up a new connection.

  1. Copy your app’s live development URL (for example, https://chatgpt-movies--development.gadget.app).
  2. In ChatGPT, go to Settings > Apps & Connectors > Create.
  3. Give it a name, e.g. “My Movies App.”
  4. Set the MCP endpoint to your Gadget app’s URL (ending with /mcp, for example, https://chatgpt-movies--development.gadget.app/mcp).
  5. Complete the OAuth flow. ChatGPT will handle authentication automatically.

Once it’s connected, you’ll see two actions in ChatGPT:

  • helloWorld
  • __getGadgetAuthTokenV1

These come from your template’s MCP server and confirm that everything’s working.

If you want to test out your app, you can start a new chat, add your app as context, and ask ChatGPT “Use my app to say hello”. A “Hello, World” widget should appear in the chat!

The user model in your app (api/models/user) contains the record for the signed-in user. In this app, it will be used to associate a movie watch list with a user, as an example of multi-tenancy in a ChatGPT app.

3. Add a watchlist data model

Before you start to modify your MCP server, you’ll need a place to store the ID of the list created in The Movie Database. Instead of storing individual movies and watch lists inside Gadget, you can use The Movie Database’s /list API to generate watch lists, and store the reference list ID in Gadget.

  1. In Gadget, create a new model called watchlist:
  2. Add a tmdbId field with the number type
  3. The model will already have timestamps, an ID, and be related to the user model

That’s all you need for now. Gadget will automatically generate a CRUD API for this model (create, read, update, delete), which you’ll use later from your front end.

4. Adding the saveToList action

Now you create an action that adds a movie to a user’s watch list. A global action is used because you need to upsert a watch list ID from The Movie Database - if a watch list record does not exist for this user, create one on The Movie Database, then store the ID in Gadget. If the watch list record does exist, we use it in follow up calls of saveToList to add movies to the list.

  1. Create a new api/actions/saveToList.ts action.
  2. Paste the following code:
// in api/actions/saveToList.ts
import { tmdbPost, type TMDBCreateListResponse } from "../tmdb";

export const run: ActionRun = async ({ params, logger, api, session }) => {
  const { movieId } = params;

  let watchlist = await api.watchlist.maybeFindFirst();
  if (!watchlist) {
    // create watchlist if it doesn't exist
    const user = await api.user.findFirst();
    const data = await tmdbPost<TMDBCreateListResponse>("/list", {
      name: `This is ${user.firstName}'s custom ChatGPT watchlist.`,
      description: "A list of movies added using ChatGPT.",
      language: "en",
    });

    if (data) {
      watchlist = await api.watchlist.create({
        tmdbId: data.list_id,
        user: {
          _link: session!.get("user"),
        },
      });
    }
  }

  const listId = watchlist?.tmdbId;
  const response = await tmdbPost<{
    status_code: number;
    status_message: string;
  }>(`/list/${listId}/add_item`, {
    media_id: movieId,
  });

  if (response.status_code !== 12 && response.status_code !== 1) {
    throw new Error(
      `Failed to add movie to watchlist: ${response.status_message}`,
    );
  }

  return { listId };
};

// define the incoming movie ID as a param
export const params = {
  movieId: { type: "number" }
};
Enter fullscreen mode Exit fullscreen mode

Because your widget requests include a bearer token (thanks to the auth tool), we can associate a watch list to a user’s ChatGPT account, enabling multi-tenancy.

5. Connecting to The Movie Database (TMDB)

Now that your backend can save data, let’s connect to the TMDB API to fetch movies.

  1. Add a TMDB_API_KEY environment variable to Gadget (Settings > Environment variables) and paste in your key.
  2. Create a helper file at api/tmdb.ts.
  3. Paste the following code into the helper file:
// in api/tmdb.ts
// TMDB API configuration
const TMDB_BASE_URL = "https://api.themoviedb.org/3";
export const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p";

export interface TMDBMovie {
  id: number;
  title: string;
  original_title: string;
  overview: string;
  release_date: string;
  poster_path: string | null;
  backdrop_path: string | null;
  vote_average: number;
  vote_count: number;
  popularity: number;
  genre_ids: number[];
  adult: boolean;
  original_language: string;
}

export interface TMDBMovieDetails extends TMDBMovie {
  runtime: number;
  genres: Array<{ id: number; name: string }>;
  budget: number;
  revenue: number;
  tagline: string;
  status: string;
  production_companies: Array<{
    id: number;
    name: string;
    logo_path: string | null;
  }>;
}

export interface TMDBResponse {
  page: number;
  results: TMDBMovie[];
  total_pages: number;
  total_results: number;
}

// Helper function to fetch from TMDB API
export async function tmdbFetch<T>(
  endpoint: string,
  params: Record<string, string> = {},
): Promise<T> {
  const url = new URL(`${TMDB_BASE_URL}${endpoint}`);
  Object.entries(params).forEach(([key, value]) => {
    url.searchParams.append(key, value);
  });

  const response = await fetch(url.toString(), {
    headers: {
      Authorization: `Bearer ${process.env.TMDB_API_KEY!}`,
      Accept: "application/json",
    },
  });
  if (!response.ok) {
    throw new Error(`TMDB API error: ${response.statusText}`);
  }
  return response.json();
}

export interface TMDBCreateListResponse {
  status_message: string;
  success: boolean;
  status_code: number;
  list_id: number;
}

export interface TMDBList {
  created_by: string;
  description: string;
  favorite_count: number;
  id: string;
  items: TMDBMovie[];
  item_count: number;
  iso_639_1: string;
  name: string;
  poster_path: string;
  total_results: number;
}

// Helper function to post to TMDB API
export async function tmdbPost<T>(
  endpoint: string,
  body: Record<string, any> = {},
): Promise<T> {
  const url = new URL(`${TMDB_BASE_URL}${endpoint}`);

  const response = await fetch(url.toString(), {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.TMDB_API_KEY!}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify(body),
  });
  if (!response.ok) {
    throw new Error(`TMDB API error: ${response.statusText}`);
  }
  return response.json();
}

// Helper to format movie data for display
export function formatMovieInfo(movie: TMDBMovie | TMDBMovieDetails): string {
  const posterUrl = movie.poster_path
    ? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
    : "No poster available";

  let info = `**${movie.title}** (${movie.release_date?.split("-")[0] || "N/A"})\n`;
  info += `Rating: ${movie.vote_average.toFixed(1)}/10 (${movie.vote_count} votes)\n`;
  info += `Overview: ${movie.overview}\n`;
  info += `Poster: ${posterUrl}\n`;

  if ("runtime" in movie && movie.runtime) {
    info += `Runtime: ${movie.runtime} minutes\n`;
  }

  if ("genres" in movie && movie.genres) {
    info += `Genres: ${movie.genres.map((g) => g.name).join(", ")}\n`;
  }

  return info;
}
Enter fullscreen mode Exit fullscreen mode

This helper file makes it easy for us to call TMDB endpoints and defines some types for our app.

6. Registering MCP tools for movie endpoints

It is finally time to modify your MCP server. Open mcp.ts again and start registering tools for the different TMDB endpoints.

  1. Paste the following code into api/mcp.ts:
// in api/mcp.ts
import { getWidgets } from "vite-plugin-chatgpt-widgets";
import { FastifyRequest } from "fastify";
import type { Server } from "gadget-server";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import path from "path";
import {
  tmdbFetch,
  tmdbPost,
  formatMovieInfo,
  TMDB_IMAGE_BASE_URL,
  type TMDBResponse,
  type TMDBMovieDetails,
  type TMDBCreateListResponse,
  TMDBList,
} from "./tmdb";

export const createMCPServer = async (request: FastifyRequest) => {
  const mcpServer = new McpServer({
    name: "movie-demo",
    version: "1.0.0",
  });

  const api = request.api.actAsSession;

  // helper functions to fetch watchlist
  const getMyWatchlist = async (request: FastifyRequest) => {
    const watchlist = await api.watchlist.maybeFindFirst();

    if (!watchlist) {
      return;
    }

    const data = await tmdbFetch<TMDBList>(`/list/${watchlist.tmdbId}`);

    const output = {
      movies: data.items.map((movie) => ({
        id: movie.id,
        title: movie.title,
        release_date: movie.release_date,
        rating: movie.vote_average,
        overview: movie.overview,
        poster_url: movie.poster_path
          ? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
          : null,
        in_watchlist: true,
      })),
      total_results: data.total_results,
    };

    return output;
  };

  const getMyWatchlistMovieIds = async (request: FastifyRequest) => {
    const watchlist = await getMyWatchlist(request);
    const ids = new Set(watchlist?.movies.map((m) => m.id) || []);
    return ids;
  };

  // Tool 1: Get now playing movies (recently released in theaters)
  mcpServer.registerTool(
    "getNowPlayingMovies",
    {
      title: "Get Now Playing Movies",
      description:
        "Get a list of movies currently playing in theaters. These are recently released movies that are available to watch right now.",
      // give an input schema to the ChatGPT LLM, allowing it to invoke this tool with different parameters as needed
      inputSchema: {
        page: z
          .number()
          .optional()
          .describe("Page number for pagination (default: 1)"),
        region: z
          .string()
          .optional()
          .describe(
            "ISO 3166-1 code to filter release dates (e.g., 'US', 'GB')",
          ),
      },
      annotations: {
        readOnlyHint: true,
      },
      _meta: {
        // render the MovieList React widget in the ChatGPT UI when this tool is invoked
        "openai/outputTemplate": "ui://widget/MovieList.html",
        "openai/toolInvocation/invoking":
          "Getting movies currently playing in theaters",
        "openai/toolInvocation/invoked": "Found currently playing movies",
      },
    },
    async ({ page = 1, region = "US" }) => {
      const data = await tmdbFetch<TMDBResponse>("/movie/now_playing?sort_by=popularity.desc", {
        page: page.toString(),
        region,
      });

      const myWatchlistIds = await getMyWatchlistMovieIds(request);

      const output = {
        movies: data.results.map((movie) => ({
          id: movie.id,
          title: movie.title,
          release_date: movie.release_date,
          rating: movie.vote_average,
          overview: movie.overview,
          poster_url: movie.poster_path
            ? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
            : null,
          in_watchlist: myWatchlistIds.has(movie.id),
        })),
        total_results: data.total_results,
      };

      return {
        content: [
          {
            type: "text",
            text: `Found ${data.total_results} movies now playing in theaters`,
          },
        ],
        structuredContent: output,
      };
    },
  );

  // Tool 2: Search for movies
  mcpServer.registerTool(
    "searchMovies",
    {
      title: "Search Movies",
      description: "Search for movies by title or keywords",
      inputSchema: {
        query: z.string().describe("Search query (movie title or keywords)"),
        page: z
          .number()
          .optional()
          .describe("Page number for pagination (default: 1)"),
        year: z.number().optional().describe("Filter by release year"),
      },
      annotations: {
        readOnlyHint: true,
      },
      _meta: {
        "openai/toolInvocation/invoking": "Searching for movies",
        "openai/toolInvocation/invoked": "Found matching movies",
        "openai/outputTemplate": "ui://widget/MovieList.html",
      },
    },
    async ({ query, page = 1, year }) => {
      const params: Record<string, string> = {
        query,
        page: page.toString(),
      };
      if (year) {
        params.year = year.toString();
      }

      const data = await tmdbFetch<TMDBResponse>("/search/movie", params);

      const output = {
        movies: data.results.map((movie) => ({
          id: movie.id,
          title: movie.title,
          release_date: movie.release_date,
          rating: movie.vote_average,
          overview: movie.overview,
          poster_url: movie.poster_path
            ? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
            : null,
        })),
        total_results: data.total_results,
      };

      return {
        content: [
          {
            type: "text",
            text: `Found ${data.total_results} movies matching "${query}"`,
          },
        ],
        structuredContent: output,
      };
    },
  );

  // Tool 3: Get popular movies
  mcpServer.registerTool(
    "getPopularMovies",
    {
      title: "Get Popular Movies",
      description: "Get a list of popular movies, ordered by popularity",
      inputSchema: {
        page: z
          .number()
          .optional()
          .describe("Page number for pagination (default: 1)"),
      },
      annotations: {
        readOnlyHint: true,
      },
      _meta: {
        "openai/toolInvocation/invoking": "Getting popular movies",
        "openai/toolInvocation/invoked": "Found popular movies",
        "openai/outputTemplate": "ui://widget/MovieList.html",
      },
    },
    async ({ page = 1 }) => {
      const data = await tmdbFetch<TMDBResponse>("/movie/popular?sort_by=popularity.desc", {
        page: page.toString(),
      });

      const myWatchlistIds = await getMyWatchlistMovieIds(request);

      const output = {
        movies: data.results.map((movie) => ({
          id: movie.id,
          title: movie.title,
          release_date: movie.release_date,
          rating: movie.vote_average,
          overview: movie.overview,
          poster_url: movie.poster_path
            ? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
            : null,
          in_watchlist: myWatchlistIds.has(movie.id),
        })),
        total_results: data.total_results,
      };

      return {
        content: [
          {
            type: "text",
            text: `Found ${data.total_results} popular movies`,
          },
        ],
        structuredContent: output,
      };
    },
  );

  // Tool 4: Get movie details
  mcpServer.registerTool(
    "getMovieDetails",
    {
      title: "Get Movie Details",
      description: "Get detailed information about a specific movie by its ID",
      inputSchema: {
        movieId: z.number().describe("The TMDB movie ID"),
      },
      annotations: {
        readOnlyHint: true,
      },
      _meta: {
        "openai/toolInvocation/invoking": "Getting movie details",
        "openai/toolInvocation/invoked": "Retrieved movie details",
        "openai/outputTemplate": "ui://widget/MovieList.html",
      },
    },
    async ({ movieId }) => {
      const movie = await tmdbFetch<TMDBMovieDetails>(`/movie/${movieId}`);

      let details = formatMovieInfo(movie);
      details += `Tagline: ${movie.tagline || "N/A"}\n`;
      details += `Status: ${movie.status}\n`;
      details += `Budget: $${movie.budget.toLocaleString()}\n`;
      details += `Revenue: $${movie.revenue.toLocaleString()}\n`;

      if (movie.production_companies.length > 0) {
        details += `Production: ${movie.production_companies.map((c) => c.name).join(", ")}\n`;
      }

      const output = {
        id: movie.id,
        title: movie.title,
        release_date: movie.release_date,
        rating: movie.vote_average,
        overview: movie.overview,
        runtime: movie.runtime,
        genres: movie.genres,
        tagline: movie.tagline,
        budget: movie.budget,
        revenue: movie.revenue,
        poster_url: movie.poster_path
          ? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
          : null,
        backdrop_url: movie.backdrop_path
          ? `${TMDB_IMAGE_BASE_URL}/original${movie.backdrop_path}`
          : null,
      };

      return {
        content: [{ type: "text", text: details }],
        structuredContent: output,
      };
    },
  );

  // Tool 5: Get upcoming movies
  mcpServer.registerTool(
    "getUpcomingMovies",
    {
      title: "Get Upcoming Movies",
      description: "Get a list of upcoming movies that will be released soon",
      inputSchema: {
        page: z
          .number()
          .optional()
          .describe("Page number for pagination (default: 1)"),
        region: z
          .string()
          .optional()
          .describe(
            "ISO 3166-1 code to filter release dates (e.g., 'US', 'GB')",
          ),
      },
      annotations: {
        readOnlyHint: true,
      },
      _meta: {
        "openai/toolInvocation/invoking": "Getting upcoming movies",
        "openai/toolInvocation/invoked": "Found upcoming movies",
        "openai/outputTemplate": "ui://widget/MovieList.html",
      },
    },
    async ({ page = 1, region = "US" }) => {
      const data = await tmdbFetch<TMDBResponse>("/movie/upcoming?sort_by=popularity.desc", {
        page: page.toString(),
        region,
      });

      const myWatchlistIds = await getMyWatchlistMovieIds(request);

      const output = {
        movies: data.results.map((movie) => ({
          id: movie.id,
          title: movie.title,
          release_date: movie.release_date,
          rating: movie.vote_average,
          overview: movie.overview,
          poster_url: movie.poster_path
            ? `${TMDB_IMAGE_BASE_URL}/w500${movie.poster_path}`
            : null,
          in_watchlist: myWatchlistIds.has(movie.id),
        })),
        total_results: data.total_results,
      };

      return {
        content: [
          {
            type: "text",
            text: `Found ${data.total_results} upcoming movies`,
          },
        ],
        structuredContent: output,
      };
    },
  );

  // Tool 6: Get watchlist
  mcpServer.registerTool(
    "getMyWatchlist",
    {
      title: "Get Movies Saved To My Watchlist",
      description:
        "Get a list of movies that the user has saved to their watchlist",
      annotations: {
        readOnlyHint: true,
      },
      _meta: {
        "openai/toolInvocation/invoking": "Getting movies on your watchlist",
        "openai/toolInvocation/invoked": "Found your watchlist of movies",
        "openai/outputTemplate": "ui://widget/MovieList.html",
      },
    },
    async () => {
      const output = await getMyWatchlist(request);

      if (!output) {
        return {
          content: [
            {
              type: "text",
              text: `You don't have a watchlist yet.`,
            },
          ],
          structuredContent: {},
        };
      }

      return {
        content: [
          {
            type: "text",
            text: `Found ${output.total_results} movies on your watchlist`,
          },
        ],
        structuredContent: output,
      };
    },
  );

  const devServer = await (
    request.server as any
  ).frontendServerManager?.devServerManager?.getServer();

  const viteHandle =
    devServer && process.env.NODE_ENV != "production"
      ? { devServer }
      : {
        manifestPath: path.resolve(
          process.cwd(),
          ".gadget/remix-dist/build/client/.vite/manifest.json",
        ),
      };

  // Pass the Vite dev server instance from wherever you can get it
  const widgets = await getWidgets("web/chatgpt-widgets", viteHandle);

  // Register each widget on an MCP server as a resource for exposure to ChatGPT
  for (const widget of widgets) {
    const resourceName = `widget-${widget.name.toLowerCase()}`;
    const resourceUri = `ui://widget/${widget.name}.html`;

    mcpServer.registerResource(
      resourceName,
      resourceUri,
      {
        title: widget.name,
        description: `ChatGPT widget for ${widget.name}`,
      },
      async () => {
        return {
          contents: [
            {
              uri: resourceUri,
              mimeType: "text/html+skybridge",
              text: widget.content,
            },
          ],
        };
      },
    );
  }

  mcpServer.registerTool(
    "__getGadgetAuthTokenV1",
    {
      title: "Get the gadget auth token",
      description:
        "Gets the gadget auth token. Should never be called by LLMs or ChatGPT -- only used for internal auth machinery.",
      _meta: {
        // ensure widgets can invoke this tool to get the token
        "openai/widgetAccessible": true,
      },
    },
    async () => {
      if (!request.headers["authorization"]) {
        return {
          structuredContent: {
            token: null,
            error: "no token found",
          },
          content: [],
        };
      }

      const [scheme, token] = request.headers["authorization"].split(" ", 2);
      if (scheme !== "Bearer") {
        return {
          structuredContent: {
            token: null,
            error: "incorrect token scheme",
          },
          content: [],
        };
      }

      return {
        structuredContent: {
          token,
          scheme,
        },
        content: [],
      };
    }
  );

  return mcpServer;
};
Enter fullscreen mode Exit fullscreen mode

Each tool should:

  1. Use Zod to define its input schema (for pagination, region, etc.)
  2. Fetch data using your TMDB helper
  3. Format the results into structured content
  4. Register a widget output URI (MovieList.html)

This structuredContent will be passed into the React widget via window.openai.toolOutput inside the ChatGPT iframe.

7. Building the React widget

Notice how there are no tool calls for adding movies to a watch list! We can use the Gadget API directly in our widget (inside the ChatGPT iframe environment) thanks to the __getGadgetAuthTokenV1 tool call and the Provider in web/chatgpt-widgets/root.tsx.

The Provider makes a single tool call to __getGadgetAuthTokenV1 and appends the OAuth token as a Authorization: Bearer <token> to any requests made with the Gadget API client. This is what allows for authenticated requests to be made from ChatGPT to your Gadget backend without having to define a separate tool call. (Tool calls are slow. Calling your app’s API directly is much faster.)

The OpenAI Apps sample repo includes reusable hooks for accessing toolOutput and widgetState, which have been included in the original template that you forked. toolOutput is how you pass in your list of movies from the backend to the widget. widgetState allows you to persist the current state of your frontend. If a user refreshes their browser tab or leaves and returns to the chat, the app will be in the same state as before.

  1. Create a new file in your web/chatgpt-widgets folder called MovieList.tsx.
  2. Paste the following code into MovieList.tsx:
// in web/chatgpt-widgets/MovieList.tsx
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "../components/ui/card";
import { Badge } from "../components/ui/badge";
import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from "../components/ui/tabs";
import {
  Star,
  Calendar,
  DollarSign,
  Clock,
  Film,
  Bookmark,
} from "lucide-react";
import { useWidgetProps, useWidgetState } from "./utils/hooks";
import { UnknownObject } from "./utils/types";
import { useEffect, useState } from "react";
import { api } from "../api";

interface Movie {
  id: number;
  title: string;
  release_date?: string;
  rating?: number;
  overview?: string;
  poster_url?: string | null;
  backdrop_url?: string | null;
  runtime?: number;
  genres?: Array<{ id: number; name: string; }>;
  tagline?: string;
  budget?: number;
  revenue?: number;
  in_watchlist?: boolean;
}

interface ToolOutput extends UnknownObject {
  movies?: Movie[];
  id?: number;
  title?: string;
  release_date?: string;
  rating?: number;
  overview?: string;
  poster_url?: string | null;
  backdrop_url?: string | null;
  runtime?: number;
  genres?: Array<{ id: number; name: string; }>;
  tagline?: string;
  budget?: number;
  revenue?: number;
  total_results?: number;
}

const MovieCard = ({
  movie,
  onWatchlistAdd,
}: {
  movie: Movie;
  onWatchlistAdd?: (movieId: number) => void;
}) => {
  return (
    <Card className="group overflow-hidden hover:shadow-2xl transition-all duration-300 border-0 bg-white hover:scale-[1.02]">
      <div className="relative">
        {/* Poster Image */}
        {movie.poster_url ? (
          <div className="relative h-96 bg-gray-100 overflow-hidden">
            <img
              src={movie.poster_url}
              alt={movie.title}
              className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
            />
            {/* Gradient Overlay */}
            <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-90" />

            {/* Rating Badge - Positioned on poster */}
            {movie.rating && (
              <div className="absolute top-4 right-4 bg-black/80 backdrop-blur-sm rounded-full px-3 py-1.5 flex items-center gap-1.5 border border-yellow-400/30">
                <Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
                <span className="text-white font-bold text-sm">
                  {movie.rating.toFixed(1)}
                </span>
              </div>
            )}

            {/* Watchlist Button */}
            <button
              className="absolute top-4 left-4 bg-black/80 backdrop-blur-sm rounded-full p-2.5 hover:bg-black/90 transition-colors border border-white/30 hover:border-white/50"
              onClick={async (e) => {
                e.stopPropagation();
                await api.saveToList({ movieId: movie.id });
                onWatchlistAdd?.(movie.id);
              }}
              aria-label="Add to watchlist"
              disabled={movie.in_watchlist}
            >
              <Bookmark
                className={`w-5 h-5 text-white ${movie.in_watchlist ? "fill-white" : ""}`}
              />
            </button>

            {/* Content Overlay at Bottom */}
            <div className="absolute bottom-0 left-0 right-0 p-5 text-white">
              <h3 className="text-xl font-bold leading-tight mb-2 line-clamp-2 drop-shadow-lg">
                {movie.title}
              </h3>

              {movie.release_date && (
                <div className="flex items-center gap-1.5 text-sm text-gray-200 mb-3">
                  <Calendar className="w-4 h-4" />
                  <span>
                    {new Date(movie.release_date).toLocaleDateString("en-US", {
                      year: "numeric",
                      month: "short",
                      day: "numeric",
                    })}
                  </span>
                </div>
              )}

              {movie.overview && (
                <p className="text-sm text-gray-200 line-clamp-3 leading-relaxed">
                  {movie.overview}
                </p>
              )}

              {/* Genres */}
              {movie.genres && movie.genres.length > 0 && (
                <div className="flex flex-wrap gap-1.5 mt-3">
                  {movie.genres.slice(0, 3).map((genre) => (
                    <Badge
                      key={genre.id}
                      variant="secondary"
                      className="text-xs bg-white/20 hover:bg-white/30 backdrop-blur-sm border-white/30 text-white"
                    >
                      {genre.name}
                    </Badge>
                  ))}
                </div>
              )}
            </div>
          </div>
        ) : (
          <div className="h-96 bg-gradient-to-br from-purple-500 via-purple-600 to-indigo-700 flex flex-col items-center justify-center relative overflow-hidden">
            {/* Background Pattern */}
            <div className="absolute inset-0 opacity-10">
              <div
                className="absolute inset-0"
                style={{
                  backgroundImage:
                    "radial-gradient(circle, white 1px, transparent 1px)",
                  backgroundSize: "20px 20px",
                }}
              />
            </div>

            <Film className="w-24 h-24 text-white opacity-40 mb-4" />

            <div className="absolute bottom-0 left-0 right-0 p-5 text-white">
              <h3 className="text-xl font-bold leading-tight mb-2">
                {movie.title}
              </h3>

              {movie.release_date && (
                <div className="flex items-center gap-1.5 text-sm text-gray-200 mb-3">
                  <Calendar className="w-4 h-4" />
                  <span>
                    {new Date(movie.release_date).toLocaleDateString()}
                  </span>
                </div>
              )}

              {movie.overview && (
                <p className="text-sm text-gray-200 line-clamp-3">
                  {movie.overview}
                </p>
              )}
            </div>

            {movie.rating && (
              <div className="absolute top-4 right-4 bg-black/50 backdrop-blur-sm rounded-full px-3 py-1.5 flex items-center gap-1.5">
                <Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
                <span className="text-white font-bold text-sm">
                  {movie.rating.toFixed(1)}
                </span>
              </div>
            )}

            {/* Watchlist Button */}
            <button
              className="absolute top-4 left-4 bg-black/50 backdrop-blur-sm rounded-full p-2.5 hover:bg-black/70 transition-colors border border-white/30 hover:border-white/50"
              onClick={async (e) => {
                e.stopPropagation();
                await api.saveToList({ movieId: movie.id });
                onWatchlistAdd?.(movie.id);
              }}
              aria-label="Add to watchlist"
              disabled={movie.in_watchlist}
            >
              <Bookmark
                className={`w-5 h-5 text-white ${movie.in_watchlist ? "fill-white" : ""}`}
              />
            </button>
          </div>
        )}
      </div>
    </Card>
  );
};

const MovieDetailView = ({ movie }: { movie: Movie; }) => {
  return (
    <div className="space-y-6">
      {/* Hero Section with Backdrop */}
      {movie.backdrop_url && (
        <div className="relative h-64 rounded-lg overflow-hidden">
          <img
            src={movie.backdrop_url}
            alt={movie.title}
            className="w-full h-full object-cover"
          />
          <div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" />
          <div className="absolute bottom-4 left-4 right-4">
            <h1 className="text-3xl font-bold text-white mb-2">
              {movie.title}
            </h1>
            {movie.tagline && (
              <p className="text-gray-200 italic">{movie.tagline}</p>
            )}
          </div>
        </div>
      )}

      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {/* Poster and Quick Stats */}
        <div className="space-y-4">
          {movie.poster_url ? (
            <Card className="overflow-hidden">
              <img
                src={movie.poster_url}
                alt={movie.title}
                className="w-full h-auto"
              />
            </Card>
          ) : (
            <Card className="aspect-[2/3] bg-gradient-to-br from-purple-400 to-indigo-600 flex items-center justify-center">
              <Film className="w-24 h-24 text-white opacity-50" />
            </Card>
          )}

          {/* Quick Stats */}
          <Card>
            <CardHeader className="pb-3">
              <CardTitle className="text-sm">Quick Stats</CardTitle>
            </CardHeader>
            <CardContent className="space-y-3">
              {movie.rating && (
                <div className="flex items-center justify-between">
                  <span className="text-sm text-gray-600 flex items-center gap-1">
                    <Star className="w-4 h-4" />
                    Rating
                  </span>
                  <Badge
                    variant="secondary"
                    className="flex items-center gap-1"
                  >
                    <Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
                    {movie.rating.toFixed(1)}
                  </Badge>
                </div>
              )}
              {movie.release_date && (
                <div className="flex items-center justify-between">
                  <span className="text-sm text-gray-600 flex items-center gap-1">
                    <Calendar className="w-4 h-4" />
                    Release Date
                  </span>
                  <span className="text-sm font-medium">
                    {new Date(movie.release_date).toLocaleDateString()}
                  </span>
                </div>
              )}
              {movie.runtime && (
                <div className="flex items-center justify-between">
                  <span className="text-sm text-gray-600 flex items-center gap-1">
                    <Clock className="w-4 h-4" />
                    Runtime
                  </span>
                  <span className="text-sm font-medium">
                    {movie.runtime} min
                  </span>
                </div>
              )}
            </CardContent>
          </Card>
        </div>

        {/* Main Content */}
        <div className="md:col-span-2 space-y-4">
          {!movie.backdrop_url && (
            <div>
              <h1 className="text-3xl font-bold mb-2">{movie.title}</h1>
              {movie.tagline && (
                <p className="text-gray-600 italic mb-4">{movie.tagline}</p>
              )}
            </div>
          )}

          <Tabs defaultValue="overview" className="w-full">
            <TabsList>
              <TabsTrigger value="overview">Overview</TabsTrigger>
              {(movie.budget || movie.revenue) && (
                <TabsTrigger value="financials">Financials</TabsTrigger>
              )}
              {movie.genres && movie.genres.length > 0 && (
                <TabsTrigger value="details">Details</TabsTrigger>
              )}
            </TabsList>

            <TabsContent value="overview" className="space-y-4">
              <Card>
                <CardHeader>
                  <CardTitle className="text-lg">Synopsis</CardTitle>
                </CardHeader>
                <CardContent>
                  <p className="text-gray-700 leading-relaxed">
                    {movie.overview || "No overview available."}
                  </p>
                </CardContent>
              </Card>
            </TabsContent>

            {(movie.budget || movie.revenue) && (
              <TabsContent value="financials" className="space-y-4">
                <Card>
                  <CardHeader>
                    <CardTitle className="text-lg">Box Office</CardTitle>
                  </CardHeader>
                  <CardContent className="space-y-4">
                    {movie.budget && movie.budget > 0 && (
                      <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
                        <span className="text-sm text-gray-600 flex items-center gap-2">
                          <DollarSign className="w-4 h-4" />
                          Budget
                        </span>
                        <span className="text-lg font-semibold">
                          ${movie.budget.toLocaleString()}
                        </span>
                      </div>
                    )}
                    {movie.revenue && movie.revenue > 0 && (
                      <div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
                        <span className="text-sm text-gray-600 flex items-center gap-2">
                          <DollarSign className="w-4 h-4" />
                          Revenue
                        </span>
                        <span className="text-lg font-semibold text-green-700">
                          ${movie.revenue.toLocaleString()}
                        </span>
                      </div>
                    )}
                    {movie.budget &&
                      movie.revenue &&
                      movie.budget > 0 &&
                      movie.revenue > 0 && (
                        <div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
                          <span className="text-sm text-gray-600 flex items-center gap-2">
                            <DollarSign className="w-4 h-4" />
                            Profit
                          </span>
                          <span
                            className={`text-lg font-semibold ${movie.revenue - movie.budget >= 0 ? "text-blue-700" : "text-red-700"}`}
                          >
                            ${(movie.revenue - movie.budget).toLocaleString()}
                          </span>
                        </div>
                      )}
                  </CardContent>
                </Card>
              </TabsContent>
            )}

            {movie.genres && movie.genres.length > 0 && (
              <TabsContent value="details" className="space-y-4">
                <Card>
                  <CardHeader>
                    <CardTitle className="text-lg">Genres</CardTitle>
                  </CardHeader>
                  <CardContent>
                    <div className="flex flex-wrap gap-2">
                      {movie.genres.map((genre) => (
                        <Badge key={genre.id} variant="outline">
                          {genre.name}
                        </Badge>
                      ))}
                    </div>
                  </CardContent>
                </Card>
              </TabsContent>
            )}
          </Tabs>
        </div>
      </div>
    </div>
  );
};

const LoadingCard = ({
  title,
  description,
}: {
  title: string;
  description: string;
}) => {
  return (
    <div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 p-6 flex items-center justify-center">
      <Card className="border-0 shadow-xl max-w-md w-full">
        <CardHeader className="text-center py-12">
          <div className="w-20 h-20 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-full flex items-center justify-center mx-auto mb-6">
            <Film className="w-10 h-10 text-purple-600" />
          </div>
          <CardTitle className="text-2xl mb-3">{title}</CardTitle>
          <CardDescription className="text-base">{description}</CardDescription>
        </CardHeader>
      </Card>
    </div>
  );
};

const MovieList = () => {
  const [state, setState] = useWidgetState<ToolOutput>();
  const [loading, setLoading] = useState(true);

  const toolOutput: ToolOutput = useWidgetProps();

  useEffect(() => {
    if (toolOutput && !state) {
      // Update state with movie info (in whatever form it takes) from tool output
      setState(toolOutput);
      setLoading(false);
    }
  }, [toolOutput, setState]);

  const handleWatchlistAdd = (movieId: number) => {
    setState((prevState) => {
      if (!prevState) return prevState;

      // Update movies array if it exists
      if (prevState.movies) {
        return {
          ...prevState,
          movies: prevState.movies.map((movie) =>
            movie.id === movieId ? { ...movie, in_watchlist: true } : movie,
          ),
        };
      }
      return prevState;
    });
  };

  if (!state) {
    if (loading) {
      return (
        <LoadingCard
          title="Loading movie data"
          description="Loading data from The Movie Database"
        />
      );
    } else {
      return (
        <LoadingCard
          title="No data available"
          description="No movie data was found. Try searching for movies or browsing popular titles."
        />
      );
    }
  }

  // Check if this is a detail view (single movie with id)
  const isDetailView = !state.movies && state.id && state.title;

  if (isDetailView) {
    const movie: Movie = {
      id: state.id!,
      title: state.title!,
      release_date: state.release_date,
      rating: state.rating,
      overview: state.overview,
      poster_url: state.poster_url,
      backdrop_url: state.backdrop_url,
      runtime: state.runtime,
      genres: state.genres,
      tagline: state.tagline,
      budget: state.budget,
      revenue: state.revenue,
    };

    return (
      <div className="p-6">
        <MovieDetailView movie={movie} />
      </div>
    );
  }

  // List view
  const movies = state.movies || [];
  const totalResults = state.total_results;

  return (
    <div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50">
      <div className="p-6 md:p-8 lg:p-10 space-y-8">
        {/* Header Section */}
        <div className="space-y-2">
          <div className="flex items-center gap-3">
            <div className="w-1 h-10 bg-gradient-to-b from-purple-600 to-indigo-600 rounded-full" />
            <div>
              <h2 className="text-4xl font-bold bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent">
                MEGA Cool Movies
              </h2>
              {totalResults && (
                <p className="text-sm text-gray-500 mt-1">
                  Showing{" "}
                  <span className="font-semibold text-gray-700">
                    {movies.length}
                  </span>{" "}
                  of{" "}
                  <span className="font-semibold text-gray-700">
                    {totalResults.toLocaleString()}
                  </span>{" "}
                  results
                </p>
              )}
            </div>
          </div>
        </div>

        {movies.length === 0 ? (
          <Card className="border-0 shadow-lg">
            <CardHeader className="text-center py-12">
              <div className="w-16 h-16 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
                <Film className="w-8 h-8 text-purple-600" />
              </div>
              <CardTitle className="text-2xl">No Movies Found</CardTitle>
              <CardDescription className="text-base mt-2">
                Try a different search or browse other categories.
              </CardDescription>
            </CardHeader>
          </Card>
        ) : (
          <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
            {movies.map((movie) => (
              <MovieCard
                key={movie.id}
                movie={movie}
                onWatchlistAdd={handleWatchlistAdd}
              />
            ))}
          </div>
        )}
      </div>
    </div>
  );
};

export default MovieList;
Enter fullscreen mode Exit fullscreen mode

This widget is just React + Tailwind, so you can style and extend it however you like.

The api client is used to save movies to the user’s watch list.

8. Running and testing the app

You’re done building!

Now it’s time to test out the app. First you need to r*efresh your ChatGPT connector* to load up the new tool calls added to the MCP server, then you can try asking ChatGPT questions like:

  1. “Show me upcoming movies.”
  2. “What movies are in theatres near me?”
  3. “Show my watch list.”

ChatGPT will call your MCP tool (e.g. getUpcomingMovies), fetch data from TMDB, return structured content to your widget, render it inside ChatGPT with your React widget.

If you open Gadget, you’ll see your watchlist model populated with TMDB list IDs linked to your user record.

Wrap-up and next steps

You just built a fully interactive ChatGPT app with:

  • Custom tool calls via MCP
  • Embedded React widgets Multi-tenant data storage through Gadget
  • Real movie data from TMDB

This foundation can power any ChatGPT-connected app: news dashboards, e-commerce stores, or productivity tools.

Gadget is building a ChatGPT connection to help speed up building ChatGPT apps. Keep an eye on our blog for an announcement.

If you have any questions, feel free to reach out on Gadget’s developer Discord!

Need more info?

Top comments (0)