# Build a ChatGPT todo list app Expected time: 15 minutes This tutorial will show you how to build everyone's favorite app, a todo list, for ChatGPT. You'll lean how to: * Modify an MCP server to add a todo list tool * Build a todo list widget using React * Use hooks to call your Gadget API and interact with the [`window.openai` API](https://developers.openai.com/apps-sdk/build/custom-ux#understand-the-windowopenai-api) * Test the widget in a ChatGPT ## Prerequisites Before starting, you will need: * A paid [ChatGPT subscription](https://chatgpt.com/pricing/) ## Step 1: Create a new Gadget app and connect to ChatGPT You will start by creating a new Gadget app and connecting to ChatGPT. 1. Create a new **ChatGPT** app at [gadget.new](https://gadget.new), **enable auth**, **Continue** to give your app a name, and create your Gadget app. 2. Click the **Connect to ChatGPT** button on your app's home page and follow the connection setup steps to create and test your connection. 3. Copy and paste the generated **MCP server URL** into ChatGPT when creating a new connection. Gadget handles OAuth for you, so you can sign in right away. User and session record can be found in Gadget at `api/models/user/data` and `api/models/session/data`. ## Step 2: Add a `todo` data model Now you can create a data model to store your todos. 1. Click the **+** button next to `api/models` to create a new model. 2. Name your model `todo`. 3. Add a new field to your model called `item` of type string. 4. Add a new field to your model called `isComplete` of type boolean. Set the default value to `false`. This creates a `todo` table in the underlying Postgres database. A CRUD (Create, Read, Update, Delete) GraphQL API and JavaScript API client are automatically generated for you. ## Step 3: Update your MCP server Widgets in ChatGPT are served from an MCP server. Your MCP server is defined in `api/mcp.js`, and ChatGPT will make requests to this server through `routes/mcp/POST.js`. Add a tool to your MCP server that allows you to serve up a todo list widget. 1. Add the following tool call to the existing `createMCPServer` function in `api/mcp.js`. Make sure to add it after the `mcpServer` has been created: ```typescript // a tool call for reading todos and displaying them in a widget mcpServer.registerTool( "listTodos", { title: "list todos", description: "this will list all of my todos", annotations: { readOnlyHint: true }, _meta: { "openai/outputTemplate": "ui://widget/TodoList.html", "openai/toolBehavior": "interactive", "openai/toolInvocation/invoking": "Prepping your todo items", "openai/toolInvocation/invoked": "Here's your todo list", }, }, async () => { const todos = await api.todo.findMany(); return { structuredContent: { todos }, content: [], }; } ); ``` The `listTodos` tool uses the `api` client to fetch the todos from the database and returns them to the widget [using `structuredContent`](https://developers.openai.com/apps-sdk/build/mcp-server#structure-the-data-your-tool-returns), which is the property used to hydrate your widgets. Because `api` is defined with `api.actAsSession`, the auth token passed to your MCP server from ChatGPT will be used to fetch todo data. This means only the current user's todos will be read from the database. ### Add todos with a tool call (optional) To be able to prompt ChatGPT to create todos for you without using the widget UI, you can also add a `createTodo` tool . This tool call is not used to add todos from the widget itself. 1. Add `zod` (major version 3) as a dependency using [Gadget's command palette](https://docs.gadget.dev/guides/development-tools/keyboard-shortcuts#keyboard-shortcuts). Make sure to enter command mode by typing `>` or clicking `Commands`: ```bash yarn add zod@^3.25.76 ``` 2. Import `zod` in `api/mcp.js` ```typescript import { z } from "zod"; ``` 3. Add the following tool call to the existing `createMCPServer` function in `api/mcp.js`. Make sure to add it after the `mcpServer` has been created: ```typescript // a tool call for adding new todos (without a widget) mcpServer.registerTool( "addTodo", { title: "add a todo", description: "add a new todo item to my list", inputSchema: { item: z.string() }, }, async ({ item }) => { // use the api client to create a new todo record const todo = await api.todo.create({ item }); return { structuredContent: { todo }, content: [], }; } ); ``` This tool call enables you to add todos without using the widget UI, by prompting ChatGPT to call the `addTodo` tool. You could prompt ChatGPT with "Add wash car to my todos" and it would call this tool to create the todo item. This tool _could_ be called from the widget UI. This tutorial uses your Gadget API client to create todos instead. This makes for much faster requests from the widget UI, as it avoids the overhead of making a tool call that runs through OpenAI's infrastructure to send a request to the MCP server. ## Step 4: Build a todo list widget The last step is to build a todo list widget using React. Your ChatGPT widgets will be served from the `web/chatgpt` folder. 1. Create a new file in `web/chatgpt/TodoList.jsx` with the following code: ```tsx import { useEffect, useState } from "react"; import { Button } from "@openai/apps-sdk-ui/components/Button"; import { Expand, Plus, TBoneRaw } from "@openai/apps-sdk-ui/components/Icon"; import { Input } from "@openai/apps-sdk-ui/components/Input"; import { EmptyMessage } from "@openai/apps-sdk-ui/components/EmptyMessage"; import { AnimateLayoutGroup } from "@openai/apps-sdk-ui/components/Transition"; import { useWidgetProps, useWidgetState, useDisplayMode, useRequestDisplayMode } from "@gadgetinc/react-chatgpt-apps"; import { useAction, useSession } from "@gadgetinc/react"; import { api } from "../api"; type Todo = { id: string; item: string | null; isComplete: boolean | null; createdAt: string; }; function FullscreenButton() { const requestDisplayMode = useRequestDisplayMode(); const displayMode = useDisplayMode(); if (displayMode === "fullscreen" || !requestDisplayMode) { return null; } return ( ); } const TodoListWidget = () => { const session = useSession(); const toolOutput = useWidgetProps(); const [state, setState] = useWidgetState<{ todos: Todo[] }>(); const [inputValue, setInputValue] = useState(""); const [{ fetching: isCreating }, createTodo] = useAction(api.todo.create); const [, updateTodo] = useAction(api.todo.update); const todos = state?.todos ?? []; // Initialize from toolOutput if available useEffect(() => { if (toolOutput?.todos && Array.isArray(toolOutput.todos) && toolOutput.todos.length > 0 && todos.length === 0) { setState({ todos: toolOutput.todos as Todo[] }); } }, [toolOutput, todos.length]); // Add a todo (form submission) const handleAddTodo = async (e: React.FormEvent) => { e.preventDefault(); const item = inputValue.trim(); if (!item) return; try { // Use Gadget API client and useAction hook to create a new todo const result = await createTodo({ item }); if (result?.data) { const { data } = result; setState((prev) => ({ ...prev, todos: [ ...(prev?.todos ?? []), { id: data.id, item: data.item, isComplete: data.isComplete, createdAt: data.createdAt.toISOString(), }, ], })); } else if (result?.error) { // Handle error if needed console.error("Failed to create todo:", result.error); } setInputValue(""); } catch (error) { // Handle error if needed console.error("Failed to create todo:", error); } }; // Complete a todo (row click) const handleToggleComplete = async (index: number) => { if (!todos[index].isComplete) { try { // Use the Gadget API client and useAction hook to complete a todo const result = await updateTodo({ id: todos[index].id, isComplete: true, }); if (result?.data) { const { data } = result; setState((prev) => ({ ...prev, todos: (prev?.todos ?? []).map((todo) => (todo.id === data.id ? { ...todo, isComplete: true } : todo)), })); } else if (result?.error) { // Handle error if needed console.error("Failed to update todo:", result.error); } } catch (error) { // Handle error if needed console.error("Failed to update todo:", error); } } }; return (