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.
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 to 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.
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-widgets/TodoList.jsx with the following code:
web/chatgpt-widgets/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-widgets/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.
Open a new chat, add your tool as a source and ask ChatGPT to “Use my app to list my todos”. Your todo list widget will be rendered inside ChatGPT.
Try adding a todo. 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.