1. What the workflow context is and why you need it
In a typical web application you have a fairly clear picture of where state lives: a database, a cache, plus something on the front end like Redux or local React state. In a ChatGPT App it’s more fun: state is spread across three worlds at once — inside the model (chat history), inside the widget (UI state), and on your server/MCP (business data).
By workflow context we will mean the entire set of data needed to answer “which step are we on” and “what’s already known.” Speaking of our training app GiftGenius, the context includes:
- recipient profile: age, gender, interests;
- budget and possibly currency;
- a list of generated ideas and which ones the user liked or hid;
- technical bits: a session or workflow identifier, status (“profile_collected”, “ideas_shown”, “checkout_started”).
This context is needed not only by you as a backend developer. The model needs it so it understands which questions have already been asked, which tools have already been called, and what the current topic is. And the user needs it so they don’t have to start from scratch when returning to the chat.
Users intuitively think that “ChatGPT remembers everything.” In reality, the model only remembers the text of the dialogue, and only while it fits in the context window. Structured things like order_id, cart_id, or “a list of liked ideas” must be stored on your server; otherwise you’ll end up with a perfect machine for generating confident but wrong statements.
2. Three layers of state: UI, LLM, business
The easiest way to understand context persistence is via a three-layer state model, the “State Triad.”
Layer table
Let’s use a small table:
| Layer | Where it lives | Lifecycle | Responsibilities | Example in GiftGenius |
|---|---|---|---|---|
| UI State | Widget (React, widgetState) | While the chat/message with the widget is open | Visual state, local input | Which cards are highlighted, form state |
| LLM Context | Chat history in OpenAI | As long as the message “fits” into the context | Dialogue understanding and reasoning | “Looking for a gift for mom, budget $50” |
| Business State | MCP / your backend (DB/Redis) | As long as you decide (persistent) | Source of truth: validated data, statuses | { step: "ideas", budget: 50, liked: [42, 51] } |
The UI layer is fast and responsive but very fragile: ChatGPT can “unmount” the iframe with the widget when you scroll up the history, then mount it again. That’s exactly why there is widgetState, which lives a bit longer than the React component and is synchronized with the ChatGPT host client.
The LLM layer gives the model a sense of a continuous dialogue, but it stores only text and tool calls. You can put your cart JSON there, but that’s essentially just inserting JSON into text — the model will not treat it like a database.
The business layer is what you as an engineer can control: it holds validated data, indexes, and order statuses. As soon as you have a serious scenario (gifting, booking, education), this layer should become the primary source of truth about state.
The main engineering problem is to keep these three layers from diverging. The user changes the budget in the widget, the model still thinks about the old one, and the database holds a third value — that’s a classic recipe for odd behavior.
3. What exactly we persist: the WorkflowContext structure
To be concrete, let’s describe a TypeScript interface for GiftGenius. Assume we already have several steps: collecting the profile, choosing a budget, generating ideas, and viewing/liking.
Let’s start with a simple structure:
// backend/types/workflow.ts
export type GiftWorkflowStep =
| "profile"
| "budget"
| "ideas"
| "checkout";
export interface GiftWorkflowContext {
id: string; // workflowId — scenario identifier
userId?: string; // if authentication is already set up
currentStep: GiftWorkflowStep;
profile?: {
age?: number;
gender?: string;
interests?: string[];
};
budget?: {
min?: number;
max?: number;
currency: string;
};
ideas?: {
id: string;
title: string;
}[];
likedIdeaIds: string[];
hiddenIdeaIds: string[];
updatedAt: number; // timestamp for TTL/cleanup
}
This is not the final schema, but the important elements are already in place. There is:
- a workflow identifier we will use to find this context;
- the current step, which helps both the widget and the model know how far we’ve progressed;
- a set of fields to be populated at individual steps;
- service fields like update time.
A note on identifiers. In this lecture, workflowId means the identifier of a specific scenario in our backend/MCP. It may match the ChatGPT dialogue session identifier (sessionId), but we do not rely on that. userId is the identifier of the user from your authentication system (if any); one user can have multiple active workflows. The id field is exactly this workflowId, which we use to look up and update the context.
In the next sections we will cover three things: where to store such objects, how to write them there, and how to retrieve them — both for the widget and for the model.
4. Where to store state: options and trade‑offs
It’s convenient to think about state persistence in two dimensions: where it is stored and how long it lives. In this section we’ll focus on the storage location and return to lifetimes in the checklist and the common mistakes section.
First, let’s sort out storage locations.
Inside the dialogue (in the prompt)
Sometimes you might think: “Let’s just return a JSON with the current state to the model each time and let it figure it out.” This works for very simple scenarios and short step chains, but quickly runs into two problems: the context‑length limit and the lack of any data integrity guarantees.
Also, the MCP protocol is stateless by nature: like HTTP, it stores no state between requests by default. To tie a tool call to a specific session, you must explicitly pass an identifier — a workflow or session id — either in the tool arguments or via metadata/headers.
Therefore, keeping business state only in the dialogue is more of a learning experiment than an architecture.
In the widget: UI + widgetState
At the UI level, we use regular React state (useState, useReducer, and so on), but as mentioned, the component can unmount. The Apps SDK provides widgetState for this; it lives outside React and is synchronized with the ChatGPT host. If you read a saved value from it when mounting the widget and write back on changes, you get a local but quite convenient storage.
This storage is great for purely visual state: which cards are collapsed, which tab you’re on, what the user typed into a form before pressing “Next.” But it doesn’t replace the server: as soon as the user opens the chat on another device or a week later, widgetState may no longer help. And building business logic on top of it is questionable.
On the server/MCP: Map, Redis, DB
Finally, the main production‑grade option: we store GiftWorkflowContext on the MCP server side or in a backend service. Since the MCP client and server are stateless by protocol, we must pass workflowId (or state_token) with every tool call to know which context to update.
There are several implementation options:
- in‑memory Map in Node.js — suitable for demos and dev environments: everything is fast but disappears on restart;
- Redis or another in‑memory cache with TTL — good for short wizard‑style scenarios (a few steps): lives for an hour or two, then can be deleted;
- a regular SQL/NoSQL database — required for scenarios like “came back in a week” or “drafts and carts.”
In this lecture we won’t dive into a specific DB; we’ll focus on the interface and understanding of what exactly should go there.
5. Simplest storage in the MCP server: Map by workflowId
Let’s start with something down‑to‑earth: a plain in‑memory Map in the MCP server where the key is workflowId. In a training demo you can simply equate it to the dialogue sessionId, but in prod it’s better to keep workflowId as a separate scenario identifier. The value in this Map will be GiftWorkflowContext. In real prod you’ll replace this with Redis or a DB, but the API will remain the same.
Assume our MCP server is in TypeScript. Add this near initialization:
// mcp/workflowStore.ts
import { GiftWorkflowContext } from "../backend/types/workflow";
const workflows = new Map<string, GiftWorkflowContext>();
export function getWorkflow(id: string): GiftWorkflowContext | undefined {
return workflows.get(id);
}
export function saveWorkflow(ctx: GiftWorkflowContext): void {
workflows.set(ctx.id, { ...ctx, updatedAt: Date.now() });
}
Next — a tool that saves the recipient’s profile. The important part is that it accepts workflowId and profile data, and internally updates/creates the corresponding context:
// mcp/tools/setProfile.ts
import { jsonSchema } from "@modelcontextprotocol/sdk"; // alias
import { getWorkflow, saveWorkflow } from "../workflowStore";
export const setProfileTool = {
name: "gift_set_profile",
description: "Saves the recipient's profile",
inputSchema: jsonSchema.object({
workflowId: jsonSchema.string(),
age: jsonSchema.number().optional(),
gender: jsonSchema.string().optional(),
interests: jsonSchema.array(jsonSchema.string()).optional()
}),
async run(input: any) {
const existing = getWorkflow(input.workflowId);
const ctx = existing ?? {
id: input.workflowId,
currentStep: "profile",
likedIdeaIds: [],
hiddenIdeaIds: []
};
ctx.profile = {
age: input.age,
gender: input.gender,
interests: input.interests ?? []
};
ctx.currentStep = "budget";
saveWorkflow(ctx);
return {
structuredContent: {
type: "profileSaved",
workflowId: ctx.id,
profile: ctx.profile,
nextStep: ctx.currentStep
}
};
}
};
This tool already solves two tasks: it saves the profile and advances currentStep to the next step. In a real project you might want to separate the tools “save data” and “move to step,” but for understanding the concept, this option is fine.
Notice the workflowId in the arguments: this parameter ties the tool call to the right context. The client side (widget or agent) must store it somewhere and pass it through.
6. Wiring with the Apps SDK: where to get workflowId and sessionId
The question “where to get workflowId” in ChatGPT Apps is a bit philosophical. The options depend on whether you use authentication, MCP directly, or the Agents SDK. In general, the options are: generate it on the server side at the first tool call or generate it in the widget and pass it down.
For a training example, let’s assume the first step is a call to an MCP tool that creates a workflow, and then the widget simply picks up its id.
Simplest option:
// mcp/tools/startWorkflow.ts
import { randomUUID } from "crypto";
import { saveWorkflow } from "../workflowStore";
export const startWorkflowTool = {
name: "gift_start_workflow",
description: "Creates a new gift selection workflow",
inputSchema: { type: "object", properties: {} },
async run() {
const id = randomUUID();
saveWorkflow({
id,
currentStep: "profile",
likedIdeaIds: [],
hiddenIdeaIds: [],
updatedAt: Date.now()
});
return {
structuredContent: {
type: "workflowStarted",
workflowId: id,
currentStep: "profile"
}
};
}
};
After that, having received the workflowId in the tool response, the model can:
- keep it in a hidden form in the context;
- pass it to the widget via structuredContent, so the widget stores this value in widgetState and starts including it in subsequent tool calls.
On the widget side, the code will look roughly like this.
7. Storing workflowId and local UI state in the widget
Assume we have an ideas list widget that wants to know which workflow it displays and remember local likes even if the component unmounts. In simplified form:
// app/widgets/GiftIdeasWidget.tsx
import { useEffect, useState } from "react";
interface Idea {
id: string;
title: string;
}
interface WidgetProps {
widgetId: string;
workflowId: string; // came from structuredContent
ideas: Idea[];
}
interface UiState {
liked: string[];
}
export function GiftIdeasWidget(props: WidgetProps) {
const [uiState, setUiState] = useState<UiState>({ liked: [] });
useEffect(() => {
window.openai.getWidgetState<UiState>(props.widgetId).then(saved => {
if (saved) setUiState(saved);
});
}, [props.widgetId]);
function toggleLike(id: string) {
const exists = uiState.liked.includes(id);
const next: UiState = {
liked: exists
? uiState.liked.filter(x => x !== id)
: [...uiState.liked, id]
};
setUiState(next);
window.openai.setWidgetState(props.widgetId, next);
// you can also call the MCP tool "gift_like_idea" here
}
return (
<ul>
{props.ideas.map(idea => (
<li key={idea.id}>
{idea.title}
<button onClick={() => toggleLike(idea.id)}>
{uiState.liked.includes(idea.id) ? "★" : "☆"}
</button>
</li>
))}
</ul>
);
}
Here widgetState is used exactly as the UI layer: we remember which ideas are highlighted. Ideally, likes should also be sent to the server (via an MCP tool or a Next.js API endpoint) so that the business layer also knows what the user selected.
It’s important not to try to build the entire workflow on widgetState. It should be an additional layer to the business context on the server.
8. Resuming the scenario: the user came back
Now let’s move on to a more interesting case: the user closed ChatGPT, returned a few hours or days later, and re‑opened the same chat. What should happen?
The ideal UX is this: the model and the App understand that the user already has an unfinished workflow, pull its context, and say something like: “You’ve already provided the profile and budget; let’s continue with choosing ideas.”
Architecturally, it looks like this:
- You store a GiftWorkflowContext on your server, tied to some userId or at least an internal workflowId.
- On a new request (or the first tool call in the dialogue), the App calls the server and asks: “Is there an active workflow for this user?”
- If there is, the server returns it and possibly a special resume flag that the model uses in its reply.
In a simple monolithic demo, you can assume the MCP server and the Next.js app live in the same repository (or even process), so we just reuse the same workflowStore from MCP in API routes.
In Next.js this could be a simple API route:
// app/api/gift/workflow/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getWorkflow } from "@/mcp/workflowStore"; // in this demo MCP and Next.js share one store
export async function GET(req: NextRequest) {
const id = req.nextUrl.searchParams.get("workflowId");
if (!id) return NextResponse.json({ error: "Missing workflowId" }, { status: 400 });
const ctx = getWorkflow(id);
if (!ctx) return NextResponse.json({ exists: false });
return NextResponse.json({
exists: true,
context: ctx
});
}
The widget (or an MCP tool) can call this endpoint when it needs to refresh state, for example on first mount or when switching steps. In the training configuration, the workflowId + storage in a Map is enough; in real prod you’ll add authorization and ownership checks.
If you use the Agents SDK or more complex orchestration, you can extend the idea to “checkpoints” — saving state at the boundaries of major steps, from which the agent can resume on restart. But that’s the topic of the next module.
9. Moving forward/back and step history
The inevitable question is: “Can we go back a step?” For users this is very natural: change the budget, adjust interests, remove an extra item from the selection.
Technically, this implies two things:
- you need to store not only the current step but also a history of decisions;
- you need to carefully recompute derived data after a rollback.
One option is to add a history field to the context that contains snapshots of steps. For example:
export interface StepSnapshot {
step: GiftWorkflowStep;
payload: any; // concrete step data
createdAt: number;
}
export interface GiftWorkflowContext {
// ...previous fields
history: StepSnapshot[];
}
When the user fills out the profile, you add to the history a step snapshot: "profile". When the budget changes — another snapshot. When rolling back to the profile you:
- update currentStep = "profile";
- optionally trim the history to the desired index;
- recompute derived values (for example, clear ideas and likes if they depend on the budget).
At the model level it’s important to synchronize: if the user pressed “Back” in the widget, you need to send a tool call that updates the business context and returns an explicit description of the new state in the response. Otherwise you’ll get the classic desync problem: the UI shows step 2 while the model is sure you’re on step 3.
At the widget level, the rollback can look like a simple button:
async function goBackToProfile() {
await fetch("/api/gift/workflow/back", {
method: "POST",
body: JSON.stringify({ workflowId, targetStep: "profile" })
});
// update the UI, clear local state
}
And then the server decides what exactly to clean in the context and what message to send to the model via the tool response.
10. How to tie it all to the model: context for reasoning
Everything we do with state is ultimately needed not only by the user but also by the LLM. The model needs to understand:
- what is already known (for example, the recipient profile and the budget);
- which steps have already been completed;
- whether there are unfinished processes.
How you deliver this information to the model depends on the App’s architecture: you can inject it into the system prompt, return it in ToolOutput in a structured form, or use special _meta/annotations fields if supported by the SDK.
A typical pattern is:
- An MCP tool returns a brief context snapshot in structuredContent: the current step, key fields, and possibly the workflowId.
- The Apps SDK turns this into a widget or text plus hidden data.
- Seeing the structuredContent, the model understands the scenario has continued and plans the next action accordingly.
In some cases, if the model has “forgotten” important parameters or started to hallucinate, you can forcibly refresh the context: call a special tool that returns the current state, and the model will “enter the context” again.
It’s important not to try to stuff the entire GiftWorkflowContext into the model down to the last field. The key pieces are enough: who the gift is for, the budget, how many ideas have been shown, whether there is an unfinished checkout.
11. Mini checklist when designing WorkflowContext
Before moving on to common mistakes, it’s useful to formulate a small set of questions that you should answer for yourself when designing a workflow context (you can literally write this next to the interface):
- What steps does the scenario have and what minimal set of data is needed for each?
This protects you from giant “just in case” JSON monsters. - What needs to be remembered only within a single chat, and what must persist across sessions and devices?
The former can stay in widgetState and prompts; the latter must go to the server database. - What will the context identifier look like?
It can be a userId + scenario combo, a separate workflowId, or both. The main thing is that you can unambiguously find the context in the database. - How will you clean up old workflows?
For a demo it’s acceptable to “never clean,” but in prod you’ll need either TTLs or background jobs that delete old workflows. - Does the user need a rollback and how will you implement it?
Will you store a tree of branches or is a linear list of steps with the ability to roll back enough?
And lastly: try to run through the scenario “the user came back a week later in a different chat.” If you can’t explain how the App learns about the old workflow and what it should show, you need to strengthen the persistent storage part.
12. Common mistakes when working with context between steps
Error #1: storing everything only in the dialogue history.
Sometimes there’s a temptation: “The model sees everything in the text; let’s just list in the prompt the budget, items, and what the user chose each time.” This approach quickly hits context limits and gives zero integrity guarantees: the model can easily “forget” an important fact or mix up identifiers. Business‑critical things (money, bookings, orders) must live in your backend/MCP as the source of truth.
Error #2: trying to build the entire workflow only on widgetState.
widgetState in the Apps SDK solves the problem of surviving UI state between widget unmounting and remounting, not long‑term workflow storage. If you try to store the profile, cart, and step history there, you’ll get chaos when switching devices and an inability to recover after a long time. The widget is responsible for visual details and local comfort. The entire scenario logic should live on the server.
Error #3: no explicit workflowId or other key.
Sometimes developers rely on implicit identifiers like conversation_id but never introduce their own workflow concept. As a result, it becomes impossible to distinguish one scenario from another, split multiple parallel workflows, or recover exactly the one you need. A simple workflowId string everywhere you have tools and API endpoints solves a ton of problems, especially in MCP, which is stateless by protocol.
Error #4: mixing UI state and business logic.
A classic situation: not only “which tab is open” is stored in widgetState, but also “which items are in the cart,” and then decisions are attempted on the server based on this state. As a result, at the slightest desync (the widget rendered but the request hasn’t arrived yet, or vice versa) the model sees one reality, the UI another, and the database a third. The boundary of responsibility should be clear: the server stores and validates business data, the widget displays it and gives the user a convenient way to change it.
Error #5: no resume and rollback scenario.
It’s very easy to draw a beautiful “happy path” where the user follows the steps perfectly, nothing breaks, ChatGPT never reloads, and the tunnel never drops. In reality, any step can fail, the user can leave halfway through, and return a week later. If you haven’t laid out the WorkflowContext structure, figured out how to find an “active” workflow, and provided “Back” and “Continue later” buttons, your scenario will be fragile and frustrating for users. A well‑designed context is the foundation for resilience, which we’ll talk about in the next lecture.
GO TO FULL VERSION