Step 1: Create a new Gadget app and connect to ChatGPT
You will start by creating a new Gadget app and connecting to ChatGPT.
Create a new ChatGPT app at gadget.new, enable auth, Continue to give your app a name, and create your Gadget app.
Click the Connect to ChatGPT button on your app's home page and follow the connection setup steps to create and test your connection.
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.
Click the + button next to api/models to create a new model.
Name your model todo.
Add a new field to your model called item of type string.
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.
Add the following tool call to the existing createMCPServer function in api/mcp.js:
api/mcp.js
JavaScript
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: [],
};
}
);
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, 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.
Add the following tool call to the existing createMCPServer function in api/mcp.js:
api/mcp.js
JavaScript
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: [],
};
}
);
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.
Create a new file in web/chatgpt/TodoList.jsx with the following code:
web/chatgpt/TodoList.jsx
React
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { FullscreenIcon } from "lucide-react";
import { useWidgetProps, useWidgetState, useDisplayMode, useRequestDisplayMode, type UnknownObject } from "@gadgetinc/react-chatgpt-apps";
import { useAction, useSession } from "@gadgetinc/react";
import { api } from "../api";
type Todo = {
id: string;
item: string;
isComplete: boolean;
createdAt: string;
};
interface TodoState extends UnknownObject {
todos: Todo[];
}
function FullscreenButton() {
const requestDisplayMode = useRequestDisplayMode();
const displayMode = useDisplayMode();
if (displayMode === "fullscreen" || !requestDisplayMode) {
return null;
}
return (
<Button
variant="secondary"
aria-label="Enter fullscreen"
className="rounded-full size-10 ml-auto"
onClick={() => requestDisplayMode("fullscreen")}
>
<FullscreenIcon />
</Button>
);
}
const TodoListWidget = () => {
const session = useSession();
// Use useWidgetState to manage the persistent todo state
const [state, setState] = useWidgetState<TodoState>({
todos: [],
});
const toolOutput: { todos: Todo[] } = useWidgetProps();
const [inputValue, setInputValue] = useState("");
const [isLoadingTodos, setIsLoadingTodos] = useState(true);
// useAction hooks for creating and updating todos
const [{ data: createData, fetching: isCreating, error: createError }, createTodo] = useAction(api.todo.create);
const [{ data: updateData, fetching: isUpdating, error: updateError }, updateTodo] = useAction(api.todo.update);
// Get todos from state, with fallback to empty array
const todos = state?.todos ?? [];
// useEffect to handle toolOutput (initial todo list state passed by structuredContent in tool call)
useEffect(() => {
// only use toolOutput if we don't have todos yet
// some reconciliation logic between widgetState and toolOutput may be needed here in a real app
if (toolOutput?.todos && todos.length === 0) {
// Update state with todos from tool output
setState((prevState) => ({
...prevState,
todos: toolOutput.todos,
}));
setIsLoadingTodos(false);
} else if (toolOutput != undefined) {
// toolOutput is available but no todos, so we're done loading
setIsLoadingTodos(false);
}
}, [toolOutput, setState, todos.length]);
// useEffect to add created todo to widgetState
useEffect(() => {
if (createData && !createError) {
setState((prevState) => {
const currentTodos = prevState?.todos ?? [];
return {
...prevState,
todos: [
...currentTodos,
{
id: createData.id,
item: createData.item,
isComplete: createData.isComplete,
createdAt: createData.createdAt.toDateString(),
} as Todo,
],
};
});
console.log("Todo added successfully:", createData);
} else if (createError) {
console.error("Failed to add todo:", createError);
}
}, [createData, createError]);
// useEffect to updated completed todo in widgetState
useEffect(() => {
if (updateData && !updateError) {
// Update the todo completion status
setState((prevState) => {
const index = prevState?.todos.findIndex((todo) => todo.id === updateData.id);
const updatedTodos = [...(prevState?.todos ?? [])];
updatedTodos[index] = {
...updatedTodos[index],
isComplete: true,
};
return {
...prevState,
todos: updatedTodos,
};
});
console.log("Todo completed successfully:", updateData);
} else if (updateError) {
console.error("Failed to complete todo:", updateError);
}
}, [updateData, updateError]);
// Add a todo (form submission)
const handleAddTodo = async (e: React.FormEvent) => {
e.preventDefault();
const item = inputValue.trim();
if (!item) return;
setInputValue("");
// Use Gadget API client and useAction hook to create a new todo
await createTodo({ item });
};
// Complete a todo (row click)
const handleToggleComplete = async (index: number) => {
if (!todos[index].isComplete) {
// Use the Gadget API client and useAction hook to complete a todo
await updateTodo({ id: todos[index].id, isComplete: true });
}
};
return (
<div className="w-full text-gray-900">
<h1 className="text-xl p-6 mb-2 h-4 flex">
{session?.user?.firstName && `${session.user.firstName}'s todos`}
<FullscreenButton />
</h1>
<div className="p-6">
{/* Add Form */}
<form onSubmit={handleAddTodo} className="flex gap-2 mb-4">
<Input
type="text"
placeholder="Add a new todo..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
required
className="flex-1"
/>
<Button type="submit" variant="outline" className="hover:bg-gray-200 active:scale-95 transition" disabled={isCreating}>
{isCreating ? "Adding..." : "➕ Add"}
</Button>
</form>
{/* Todo List */}
<ul className="list-none p-0 m-0">
{todos.length === 0 && isLoadingTodos && (
<>
{[...Array(4)].map((_, index) => (
<li key={`skeleton-${index}`} className="flex justify-between items-center py-3 px-2 border-b border-gray-200">
<Skeleton className="flex-1 h-5 mr-4" />
<Skeleton className="h-4 w-20" />
</li>
))}
</>
)}
{todos.map((todo, index) => (
<li
key={todo.id ?? index}
className={`flex justify-between items-center py-3 px-2 border-b border-gray-200 hover:bg-gray-50 transition ${
todo.isComplete ? "opacity-70" : ""
}`}
>
<span
onClick={() => handleToggleComplete(index)}
className={`flex-1 text-base cursor-pointer ${todo.isComplete ? "line-through text-gray-400" : "text-gray-800"}`}
>
{todo.item}
</span>
<span className="text-sm text-gray-500 ml-4 whitespace-nowrap">{new Date(todo.createdAt).toLocaleDateString()}</span>
</li>
))}
{isCreating && (
<li className="flex justify-between items-center py-3 px-2 border-b border-gray-200">
<Skeleton className="flex-1 h-5 mr-4" />
<Skeleton className="h-4 w-20" />
</li>
)}
</ul>
</div>
</div>
);
};
export default TodoListWidget;
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { FullscreenIcon } from "lucide-react";
import { useWidgetProps, useWidgetState, useDisplayMode, useRequestDisplayMode, type UnknownObject } from "@gadgetinc/react-chatgpt-apps";
import { useAction, useSession } from "@gadgetinc/react";
import { api } from "../api";
type Todo = {
id: string;
item: string;
isComplete: boolean;
createdAt: string;
};
interface TodoState extends UnknownObject {
todos: Todo[];
}
function FullscreenButton() {
const requestDisplayMode = useRequestDisplayMode();
const displayMode = useDisplayMode();
if (displayMode === "fullscreen" || !requestDisplayMode) {
return null;
}
return (
<Button
variant="secondary"
aria-label="Enter fullscreen"
className="rounded-full size-10 ml-auto"
onClick={() => requestDisplayMode("fullscreen")}
>
<FullscreenIcon />
</Button>
);
}
const TodoListWidget = () => {
const session = useSession();
// Use useWidgetState to manage the persistent todo state
const [state, setState] = useWidgetState<TodoState>({
todos: [],
});
const toolOutput: { todos: Todo[] } = useWidgetProps();
const [inputValue, setInputValue] = useState("");
const [isLoadingTodos, setIsLoadingTodos] = useState(true);
// useAction hooks for creating and updating todos
const [{ data: createData, fetching: isCreating, error: createError }, createTodo] = useAction(api.todo.create);
const [{ data: updateData, fetching: isUpdating, error: updateError }, updateTodo] = useAction(api.todo.update);
// Get todos from state, with fallback to empty array
const todos = state?.todos ?? [];
// useEffect to handle toolOutput (initial todo list state passed by structuredContent in tool call)
useEffect(() => {
// only use toolOutput if we don't have todos yet
// some reconciliation logic between widgetState and toolOutput may be needed here in a real app
if (toolOutput?.todos && todos.length === 0) {
// Update state with todos from tool output
setState((prevState) => ({
...prevState,
todos: toolOutput.todos,
}));
setIsLoadingTodos(false);
} else if (toolOutput != undefined) {
// toolOutput is available but no todos, so we're done loading
setIsLoadingTodos(false);
}
}, [toolOutput, setState, todos.length]);
// useEffect to add created todo to widgetState
useEffect(() => {
if (createData && !createError) {
setState((prevState) => {
const currentTodos = prevState?.todos ?? [];
return {
...prevState,
todos: [
...currentTodos,
{
id: createData.id,
item: createData.item,
isComplete: createData.isComplete,
createdAt: createData.createdAt.toDateString(),
} as Todo,
],
};
});
console.log("Todo added successfully:", createData);
} else if (createError) {
console.error("Failed to add todo:", createError);
}
}, [createData, createError]);
// useEffect to updated completed todo in widgetState
useEffect(() => {
if (updateData && !updateError) {
// Update the todo completion status
setState((prevState) => {
const index = prevState?.todos.findIndex((todo) => todo.id === updateData.id);
const updatedTodos = [...(prevState?.todos ?? [])];
updatedTodos[index] = {
...updatedTodos[index],
isComplete: true,
};
return {
...prevState,
todos: updatedTodos,
};
});
console.log("Todo completed successfully:", updateData);
} else if (updateError) {
console.error("Failed to complete todo:", updateError);
}
}, [updateData, updateError]);
// Add a todo (form submission)
const handleAddTodo = async (e: React.FormEvent) => {
e.preventDefault();
const item = inputValue.trim();
if (!item) return;
setInputValue("");
// Use Gadget API client and useAction hook to create a new todo
await createTodo({ item });
};
// Complete a todo (row click)
const handleToggleComplete = async (index: number) => {
if (!todos[index].isComplete) {
// Use the Gadget API client and useAction hook to complete a todo
await updateTodo({ id: todos[index].id, isComplete: true });
}
};
return (
<div className="w-full text-gray-900">
<h1 className="text-xl p-6 mb-2 h-4 flex">
{session?.user?.firstName && `${session.user.firstName}'s todos`}
<FullscreenButton />
</h1>
<div className="p-6">
{/* Add Form */}
<form onSubmit={handleAddTodo} className="flex gap-2 mb-4">
<Input
type="text"
placeholder="Add a new todo..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
required
className="flex-1"
/>
<Button type="submit" variant="outline" className="hover:bg-gray-200 active:scale-95 transition" disabled={isCreating}>
{isCreating ? "Adding..." : "➕ Add"}
</Button>
</form>
{/* Todo List */}
<ul className="list-none p-0 m-0">
{todos.length === 0 && isLoadingTodos && (
<>
{[...Array(4)].map((_, index) => (
<li key={`skeleton-${index}`} className="flex justify-between items-center py-3 px-2 border-b border-gray-200">
<Skeleton className="flex-1 h-5 mr-4" />
<Skeleton className="h-4 w-20" />
</li>
))}
</>
)}
{todos.map((todo, index) => (
<li
key={todo.id ?? index}
className={`flex justify-between items-center py-3 px-2 border-b border-gray-200 hover:bg-gray-50 transition ${
todo.isComplete ? "opacity-70" : ""
}`}
>
<span
onClick={() => handleToggleComplete(index)}
className={`flex-1 text-base cursor-pointer ${todo.isComplete ? "line-through text-gray-400" : "text-gray-800"}`}
>
{todo.item}
</span>
<span className="text-sm text-gray-500 ml-4 whitespace-nowrap">{new Date(todo.createdAt).toLocaleDateString()}</span>
</li>
))}
{isCreating && (
<li className="flex justify-between items-center py-3 px-2 border-b border-gray-200">
<Skeleton className="flex-1 h-5 mr-4" />
<Skeleton className="h-4 w-20" />
</li>
)}
</ul>
</div>
</div>
);
};
export default TodoListWidget;
Widget explanation
Tailwind and pre-built UI components are already set up and ready to use in your web folder.
When building your widgets in Gadget, you can use your api client and React hooks to fetch and mutate data from your Gadget API.
The Provider in web/chatgpt/root.jsx allows you to make authenticated requests to your Gadget API from your ChatGPT widgets, and helps handle data multi-tenancy.
You can also use hook from the @gadgetinc/react-chatgpt-apps package to interact with ChatGPT's window.openai API. In this tutorial, hooks are used to:
Hydrate the widget from window.openai.toolOutput, which is how structuredContent is passed to your widget from the MCP server.
Save your widget state to window.openai.widgetState, which enables persistence of your widget's state across chat reloads/refreshes.
Handle fullscreen display mode for your widget.
Step 5: Test the widget
You are done building, it is time to test your app!
Refresh your MCP connection in ChatGPT by going back to your apps Settings and clicking Refresh. This is required because updates were made to the MCP server. After the refresh is complete, you should see listTodos under Actions.
If you added in the optional addTodo tool, ask ChatGPT to "Add 'buy groceries' to my todos" to create a todo using the tool call.
Ask ChatGPT to “Show me my todos”. Your todo list widget, with any created todos, will be rendered inside ChatGPT.
Try adding a todo in the widget. See the created record in Gadget at api/models/todo/data, and notice that tenancy is enforced as the record is related to the authenticated user.
Refresh the browser tab and notice that the todo list is persisted across refreshes, thanks to the window.openai.widgetState hook.
If you use your app to render your todo list inside the browser itself, you can: expand into fullscreen mode, then click the "punchout" button in the top right corner. This button is enabled with the openai/widgetDomain meta config set on resources defined in api/mcp.js.
This will open your Gadget web app (code in web/) in a panel next to your ChatGPT widget. This enables you to build unique experiences and interactions between your core web app and a ChatGPT widget.