1. From ToolOutput to a React component: the overall data flow
In the previous lecture we covered how the server-side tool produces ToolOutput — a structured response for the model and the widget. Now let’s look at the second half of this path: how this ToolOutput gets into the widget and turns into the UI.
To avoid treating what’s happening like magic, let’s walk through the data path from the user to your widget once more. In simplified form it looks like this:
- The user asks a question in chat.
- GPT analyses the request, looks at the list of tools and decides: “suggest_gifts will help here.”
- GPT forms a tool call with a name and arguments (ToolInput) and sends it to your server (MCP or backend).
- The server executes the tool logic and returns a result as ToolOutput — structured JSON with data, plus a text summary for the model.
- ChatGPT receives ToolOutput and passes it on: to the model (to continue the dialogue) and to your widget via the Apps SDK (window.openai.toolOutput or hooks).
- Your widget — an ordinary React component — reads toolOutput and renders the UI.
Schematically, it can be depicted like this:
flowchart TD U[User] -->|chat request| GPT[GPT] GPT -->|callTool: suggest_gifts| B[Backend/MCP] B -->|"ToolOutput (JSON)"| GPT GPT -->|passes toolOutput| W["Widget (React)"] W -->|cards, lists| U
It’s important to fix this idea: ToolOutput isn’t just “the server response.” It is also your render instruction for the widget and, at the same time, context for the model. A good app is one where this JSON turns into a convenient interface, not something a developer scrolls through in DevTools.
2. ToolOutput anatomy: what’s inside
The tool result format in the Apps SDK is split into three logical blocks: structuredContent, content, and _meta (which arrives in the widget under the name toolResponseMetadata).
Roughly, it can be represented like this:
{
"structuredContent": { /* data for the UI + model */ },
"content": "A brief textual summary for the model and the user",
"_meta": { /* service data for the widget only */ }
}
The table shows who sees what:
| Field | Who sees it | What it’s used for |
|---|---|---|
|
Model + widget | Primary structured data (lists, objects, parameters) |
|
Model + user (in text) | A short summary that GPT may insert into its reply |
|
Widget only | Service data not needed by the model (IDs, versions, keys, etc.) |
The Apps SDK documentation emphasizes that the pair structuredContent / content goes into the model and can be used in its subsequent responses. The _meta field remains hidden and is only available inside the widget via toolResponseMetadata.
ToolOutput example for GiftGenius
Suppose our tool suggest_gifts on the server returns roughly this body:
{
"structuredContent": {
"items": [
{
"id": "boardgame-cozy-strategy",
"title": "Cozy Strategy Board Game",
"price": 39.99,
"currency": "USD",
"score": 0.92,
"tags": ["board_game","strategy","2-4_players"]
}
]
},
"content": "Found a few gift ideas. The widget below shows them as cards.",
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"experimentBucket": "A"
}
}
}
Here, structuredContent.items is what your React widget will render; content can be used by the model to explain to the user what’s happening; _meta.giftGenius is internal information that only your UI or analytics needs (for example, which catalog version to use for links).
It’s precisely structuredContent that you’ll look at in JSX instead of manually parsing arbitrary JSON from the server.
3. Getting ToolOutput in the widget: window.openai and hooks
Time to switch from JSON talk to code. How does this ToolOutput even get into your React component?
The Apps SDK template does this in two main ways: either directly via window.openai.toolOutput, or, conveniently, via ready-made React hooks (useWidgetProps, useToolOutput and the like). The recommended approach is to use hooks so you don’t touch window.openai directly and to have more testable and safer code.
The simplest option: directly from window.openai
For understanding, you can look at the “bare” option:
'use client';
function RawToolOutputDebug() {
const toolOutput = (window as any).openai?.toolOutput;
return (
<pre>{JSON.stringify(toolOutput, null, 2)}</pre>
);
}
You shouldn’t do this in production, of course, but it’s fine for debugging and a quick first look.
Practical option: via a React hook
It’s much more convenient to wrap access to window.openai in a small hook and work with a typed object. Let our hypothetical SDK provide a useWidgetProps hook that returns toolOutput and toolResponseMetadata.
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftWidgetRoot() {
const { toolOutput, toolResponseMetadata } = useWidgetProps();
// For now, just output the number of gifts
const items = toolOutput?.structuredContent?.items ?? [];
return (
<div>
Gifts found: {items.length}
</div>
);
}
In a real template the hook name may differ, but the idea is always the same: the SDK pulls data from window.openai and gives it to your component as props or via context. That’s much simpler than reaching into the global object every time and, in addition, lets you easily substitute the data source in tests (for example, by injecting a toolOutput fixture).
4. Rendering gifts: from structuredContent to JSX
On to the fun part: take structuredContent.items and draw cards from them. Don’t forget our widget is a normal React client component in Next.js ('use client' at the top of the file).
First, define the type of a single gift:
type GiftItem = {
id: string;
title: string;
price: number;
currency: string;
tags?: string[];
};
Now write a small card component:
function GiftCard({ gift }: { gift: GiftItem }) {
return (
<div className="gift-card">
<div className="gift-title">{gift.title}</div>
<div className="gift-price">
{gift.price} {gift.currency}
</div>
</div>
);
}
And a list component that takes data from toolOutput:
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftList() {
const { toolOutput } = useWidgetProps();
const items = (toolOutput?.structuredContent?.items ?? []) as GiftItem[];
return (
<div className="gift-list">
{items.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
</div>
);
}
Notice how similar this is to ordinary React code. The only “magic” is the data source: instead of props or fetch we read toolOutput from the ChatGPT container.
And yes, it’s fine at first to add as GiftItem[]. Later you can carefully type structuredContent via shared types with the backend (for example, use Zod / JSON Schema → TS types), but this is enough for a demo.
5. UI states around ToolOutput: loading, empty, error
An app that only shows cards when you get lucky and stays silent otherwise isn’t very friendly. You should explicitly handle at least four states: while the tool is executing, when data isn’t there yet, when there’s a result, and when something went wrong.
The Apps SDK usually provides some information about the status of a tool invocation: via a list of tool invocations (useToolInvocations) or flags related to toolOutput. For this lecture, a simple model is enough: if toolOutput doesn’t exist yet — we’re in the “loading” state; if it exists but the list is empty — “empty”; if an error arrived — “error.”
For simplicity, let’s assume that in case of an error the server puts an error field into structuredContent, and the ok flag at the root of toolOutput is false. We already discussed this scheme in the previous topic on the server implementation, when we designed the tool response contract.
type ToolOutput = {
ok: boolean;
structuredContent?: {
items?: GiftItem[];
error?: { code: string; message: string };
};
};
Now update our list component:
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftListWithStates() {
const { toolOutput } = useWidgetProps() as { toolOutput?: ToolOutput };
if (!toolOutput) {
return <div>Finding gifts…</div>;
}
if (!toolOutput.ok) {
const msg = toolOutput.structuredContent?.error?.message
?? 'Failed to get recommendations.';
return <div>Error: {msg}</div>;
}
const items = toolOutput.structuredContent?.items ?? [];
if (items.length === 0) {
return <div>No gifts matched your criteria. Try adjusting the parameters.</div>;
}
return (
<div className="gift-list">
{items.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
</div>
);
}
Such code already gives the user a reasonable experience:
- While the tool is working, it’s clear that something is happening.
- If something crashes, there’s a clear message, not a blank screen.
- If nothing is found, we don’t pretend it’s normal — we plainly explain what happened.
In production you’ll likely replace the “Finding gifts…” text with a small skeleton or spinner. For complex errors you can let GPT formulate a human-readable explanation. But the basic structure of the components will remain the same.
6. Using _meta and toolResponseMetadata in the UI
We’ve already learned to render the core data from structuredContent and handle the basic loading/empty/error states. There’s one more important piece of ToolOutput that the model doesn’t use — the _meta field.
Let’s return to the _meta field. It’s not visible to the model but arrives in your widget as toolResponseMetadata (the name might differ, but the idea is the same).
This is a great place for things that shouldn’t affect GPT’s reasoning but matter to the UI:
- catalog or configuration versions;
- internal campaign/experiment A/B IDs;
- flags for which “buttons” to show the user;
- any technical stuff you don’t want to mix with domain data.
For example, the server can return such _meta:
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"showExperimentalBadges": true
}
}
The widget can read this and, say, render a “New idea” badge on some cards.
type GiftMeta = {
giftGenius?: {
catalogVersion: string;
showExperimentalBadges?: boolean;
};
};
export function GiftListWithMeta() {
const { toolOutput, toolResponseMetadata } = useWidgetProps() as {
toolOutput?: ToolOutput;
toolResponseMetadata?: GiftMeta;
};
const meta = toolResponseMetadata?.giftGenius;
const items = toolOutput?.structuredContent?.items ?? [];
return (
<div>
{meta && (
<div className="catalog-version">
Catalog as of {meta.catalogVersion}
</div>
)}
<div className="gift-list">
{items.map(gift => (
<GiftCard
key={gift.id}
gift={gift}
/>
))}
</div>
</div>
);
}
The model has nothing to do with this: it doesn’t know about catalogVersion and showExperimentalBadges, but your UI can use them as it sees fit.
The documentation emphasizes precisely this separation: data that matter for the dialogue and the model’s reasoning go into structuredContent and content; everything that’s purely UI-technical goes into _meta / toolResponseMetadata.
7. A bit about ToolInvocation statuses and “Running X…”
While the tool runs, ChatGPT itself shows the user what’s happening: a status appears at the top of the chat like “Running GiftGenius…” or “Calling an external app.” That’s not you printing strings, but the ChatGPT host environment reacting to the tool invocation metadata.
Under the hood this is described via service keys of the form _meta["openai/toolInvocation/invoking"] and _meta["openai/toolInvocation/invoked"], which signal that an action is in progress or completed. These fields are used by the platform to display status and, as a rule, you don’t need to touch them: the SDK handles this for you on the server side.
For UX this yields a nice bonus: even if the widget hasn’t yet rendered a skeleton, the user can already see that the system is doing something. Your job is to complement this global status with local states like “Finding gifts…” and a skeleton in the widget, as we did above.
8. Data size and performance: don’t shove the whole world into structuredContent
Let’s separately address the question “how much can we stuff into structuredContent.” It’s tempting to think: “I’ve got the whole gift catalog — let’s return it entirely and let the widget filter it.” In practice, you shouldn’t do that.
First, structuredContent goes into the model’s (LLM) context, and the total token budget is limited. The documentation and practical guides strongly recommend keeping the volume modest: this isn’t a data store, but the result of a single action.
Second, the larger the payload, the slower the response arrives and the higher the chance of hitting limits or getting unexpected truncation/errors.
A sensible approach is:
- The backend filters and sorts in advance, returning exactly what’s needed for the current step — for example, the top 10–20 gifts.
- If you need subsequent pages, that’s a separate action (a new tool call, a new ToolOutput).
- For purely UI things (for example, a list of all possible tags for filtering) you can use _meta, but don’t overdo it either.
In the module on state we already discussed the concept “the backend is the source of truth, and the widget is a cache/view.” Same here: the tool result is a neat “slice” of state at the moment of invocation, not a full copy of your database.
9. Tying in widget state and the further dialogue
Even though this lecture is officially about ToolOutput → UI, we can’t ignore another important piece living nearby — widgetState. It lets you remember user choice between renders and turn your widget from a mere storefront into a proper wizard or “gift configurator.”
A typical scenario looks like this:
- The first ToolOutput brings a list of gifts.
- The user clicks one of the cards.
- The widget writes which gift is selected into widgetState and, possibly, sends a follow-up or a new tool call for details.
- Subsequent ToolOutputs rely on that choice.
In code this looks like ordinary React state plus a call to setWidgetState, which saves the choice on the ChatGPT side. The only difference is that this state is available to both the model and your backend, so you should keep it compact and avoid storing secrets there.
We’ll cover this in detail in the modules on multi-step workflows and follow-ups. It’s already useful to think like this: ToolOutput gives you a “data slice” from the server, and widgetState is the context of the user’s choices around that slice.
Common mistakes when working with ToolOutput → UI
Mistake #1: “The UI renders the raw JSON tree without adapting it for the user.”
Sometimes for debugging you want to just do <pre>{JSON.stringify(toolOutput)}</pre> and stop there. That’s OK for development, but in production the user sees a structure you’re proud of but they don’t understand. It’s important to wrap structuredContent into meaningful components (lists, cards, tables) as early as possible, rather than making a person read a tokenized server response.
Mistake #2: Mixing domain data and technical metadata in structuredContent.
Code is much cleaner if you separate “what should be visible to the model and the user” from “what’s needed only for the UI and analytics.” Technical fields — experimental flags, catalog versions, idempotency keys — belong in _meta / toolResponseMetadata. When all of this is mixed inside structuredContent, it’s harder to evolve the contract and test model behavior.
Mistake #3: No explicit loading, empty-result, and error states.
An empty <div></div> instead of “Nothing found” or “Something went wrong” is a straight path to the user deciding “The app doesn’t work.” Even minimal text placeholders and a simple skeleton dramatically improve UX. Don’t rely solely on the ChatGPT system status “Running X…” — the widget should also communicate what’s happening to it.
Mistake #4: Trying to cram the whole world into a single ToolOutput.
Returning an entire product catalog, the user’s history, and the server logs all in one structuredContent is a bad idea. It hits the model’s limits, slows down the response, and complicates the UI. It’s better to return exactly the amount of data needed for the current step (list page, details of the selected item, etc.), and handle subsequent steps with separate tool calls.
Mistake #5: Hard-wiring the UI to an unstable response shape without types.
If you write toolOutput.structuredContent.items[0].whatever all over the code without checking for field existence and without having types, any evolution of the schema on the server will cause the widget to crash. Either synchronize types with a JSON Schema (generate TS types) or at least handcraft interfaces (GiftItem, ToolOutput) and carefully handle optional fields.
Mistake #6: Ignoring _meta and overloading the model with “extra” fields.
It’s tempting to throw everything into structuredContent because “it’s JSON, there’s no harm in extra fields.” But every field increases the model’s context, and many things aren’t needed by the model at all. If the information shouldn’t affect GPT’s reasoning and isn’t needed in the text response, put it in _meta and use it only in the widget.
Mistake #7: Directly accessing window.openai from a dozen components.
Yes, window.openai.toolOutput works, but when half the app pokes the global variable, debugging and testing become a nightmare. It’s far better to wrap it once in a hook/context (useWidgetProps/useToolOutput) and then use normal props and typed objects. It’s cleaner and easier to swap with fixtures in Storybook/tests.
GO TO FULL VERSION