Interactive MCP Apps: Building Two-Way UIs with useAppState (July 2026)

Build UIs that can be interacted with by humans or agents with sunpeak.
TL;DR: useAppState works like React useState, but it syncs chosen UI state to the AI host through updateModelContext. The model can read that state on the next turn, so clicks, form values, selected rows, and checklist progress become context the model can use. Import it from sunpeak alongside useToolData.
Every tutorial for MCP Apps starts with the same shape: tool returns data, component renders it as a card. That covers a lot of apps. But it leaves out the interactive half of the picture.
When a user clicks “Approve,” the model should know they clicked “Approve.” When a user checks three items off a list, the model should see which three. useAppState is how that works in MCP Apps: it turns selected UI state into model context without making every interaction a new chat message.
Why Not Just useState
React useState is local to the component instance. The host does not know about it. It disappears when the iframe reloads. For UI-only state like open/closed panels or hover effects, that is fine.
useAppState does something different: it syncs state to the host through updateModelContext. When the state changes, the host stores it and can make it available to the model on a later turn. The model can then respond based on what the user did.
The current MCP Apps standard calls this host request ui/update-model-context. It is not a message send. It updates the context the model may receive later, usually after the next user message or a UI-triggered sendMessage. It also behaves like a replacement: each update should contain the full model-visible state you want the host to keep.
ChatGPT also exposes window.openai.widgetState and window.openai.setWidgetState for ChatGPT-specific widget persistence. Those are useful for UI-only state inside one ChatGPT message, but they are not the portable API to build around. For new cross-host MCP Apps, start with the MCP Apps bridge and use ChatGPT extensions only when the feature is truly ChatGPT-specific.
useAppState wraps the portable model-visible path into one React hook so you write resource code instead of host bridge code.
The hook signature is almost identical to useState:
import { useAppState } from 'sunpeak';
const [state, setState] = useAppState({ confirmed: false });
The difference is that setState sends the new state to the host, not just a local re-render. This state can survive resource re-renders within the same host-scoped resource instance, and the model can read the synced context on a later turn.
Use useState for hover states, open/closed panels, active tabs, animation triggers, and scroll position.
Use useAppState for decisions, selected rows, staged form values, approval choices, filters, checklist progress, and anything else the model should see.
The Current MCP Apps State Contract
An interactive MCP App has two data directions:
- The model and server send data into the UI. Your resource reads that data with
useToolData, usually from the tool result’sstructuredContent. - The user changes state in the UI. Your resource writes the state the model should know about with
useAppState, which callsupdateModelContextfor you.
Keep those two directions separate. structuredContent is the initial tool result. It should be concise, typed, and useful to both the model and the UI. useAppState is what changed after render. It should describe the user’s current interaction state, not duplicate the whole tool result.
For an approval card, the tool result might say:
{
"title": "Delete production snapshots",
"description": "This will permanently delete 14 snapshots.",
"confirmLabel": "Delete",
"cancelLabel": "Keep them"
}
The app state after a click should be much smaller:
{
"decision": "confirmed",
"decidedAt": "2026-07-24T15:30:00.000Z"
}
That state gives the model enough context to continue. It does not need the whole UI tree, hidden IDs, internal API cursors, OAuth tokens, analytics fields, or raw database rows.
Use this rule when deciding what to sync: if the model needs it to answer the next user message, put it in useAppState. If only the UI needs it, keep it in component state, ChatGPT widget state, or your backend.
A Practical Example: Approval Flow
One common interactive pattern in Claude Connectors and ChatGPT Apps is an approval flow. The model shows a review card. The user confirms or rejects. The model acts on the decision.
Here is a simple approval component. It shows a proposal and two buttons. The model knows which button the user pressed.
import { useToolData, useAppState, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
title: 'Approval',
description: 'Request user confirmation before taking an action',
};
interface ApprovalData {
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
}
interface ApprovalState {
decision: 'confirmed' | 'cancelled' | null;
decidedAt: string | null;
}
export function ApprovalResource() {
const { output } = useToolData<unknown, ApprovalData>(undefined, undefined);
const [state, setState] = useAppState<ApprovalState>({
decision: null,
decidedAt: null,
});
if (!output) return null;
const handleConfirm = () => {
setState({ decision: 'confirmed', decidedAt: new Date().toISOString() });
};
const handleCancel = () => {
setState({ decision: 'cancelled', decidedAt: new Date().toISOString() });
};
return (
<SafeArea className="p-5 font-sans max-w-sm mx-auto space-y-4">
<div>
<h1 className="text-lg font-bold">{output.title}</h1>
<p className="text-sm text-gray-600 mt-1">{output.description}</p>
</div>
{state.decision === null ? (
<div className="flex gap-3">
<button
onClick={handleCancel}
className="flex-1 py-2 px-4 rounded-lg border border-gray-300 text-sm font-medium"
>
{output.cancelLabel ?? 'Cancel'}
</button>
<button
onClick={handleConfirm}
className="flex-1 py-2 px-4 rounded-lg bg-blue-600 text-white text-sm font-medium"
>
{output.confirmLabel ?? 'Confirm'}
</button>
</div>
) : (
<div className="text-center py-2">
<p className="font-medium text-green-700">
{state.decision === 'confirmed' ? 'Confirmed' : 'Cancelled'}
</p>
{state.decidedAt && (
<p className="text-xs text-gray-400 mt-1">
{new Date(state.decidedAt).toLocaleTimeString()}
</p>
)}
</div>
)}
</SafeArea>
);
}
The tool that triggers this resource passes the title and description as structuredContent. After the user clicks, the model can see { decision: 'confirmed', decidedAt: '...' } on a later turn and act accordingly, such as completing a purchase, sending a message, or executing a write operation.
The tool looks like this:
// src/tools/request-approval.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
resource: 'approval',
title: 'Request Approval',
description: 'Show a confirmation dialog before taking an irreversible action',
annotations: { destructiveHint: true },
};
export const schema = {
title: z.string().describe('Action title'),
description: z.string().describe('What will happen if the user confirms'),
confirmLabel: z.string().optional().describe('Confirm button text'),
cancelLabel: z.string().optional().describe('Cancel button text'),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function (args: Args, _extra: ToolHandlerExtra) {
return {
structuredContent: {
title: args.title,
description: args.description,
confirmLabel: args.confirmLabel ?? 'Confirm',
cancelLabel: args.cancelLabel ?? 'Cancel',
},
};
}
Note destructiveHint: true in the annotations. The approval resource is typically used before destructive actions, so the tool metadata should tell the host and review teams that the action needs care. See the Claude Connectors tutorial and the MCP App tool metadata guide for the broader metadata pattern.
setState: Replacement and Functional Updates
setState from useAppState accepts two forms, just like React’s useState.
You can pass a full replacement object:
setState({ decision: 'confirmed', decidedAt: new Date().toISOString() });
Or you can pass a callback that receives the previous state:
setState(prev => ({ ...prev, decision: 'confirmed', decidedAt: new Date().toISOString() }));
The callback form is useful when you have many fields and only want to update a few, because it avoids accidentally dropping fields. Either way, the new state replaces the old state entirely. There is no shallow merge like React class component setState.
For complex state with many fields, the callback pattern keeps things readable:
const [state, setState] = useAppState({ step: 1, answers: {}, complete: false });
// Advance to step 2 without losing answers
setState(prev => ({ ...prev, step: 2 }));
A Checklist Example
Here is a checklist where the user checks items off a list and the model knows which are done:
import { useToolData, useAppState, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
title: 'Checklist',
description: 'Interactive task checklist with model-visible completion state',
};
interface ChecklistData {
title: string;
items: Array<{ id: string; label: string }>;
}
interface ChecklistState {
checked: string[];
}
export function ChecklistResource() {
const { output } = useToolData<unknown, ChecklistData>(undefined, undefined);
const [state, setState] = useAppState<ChecklistState>({ checked: [] });
if (!output) return null;
const toggle = (id: string) => {
setState(prev => {
const checked = prev.checked.includes(id)
? prev.checked.filter((c) => c !== id)
: [...prev.checked, id];
return { checked };
});
};
return (
<SafeArea className="p-5 font-sans max-w-sm mx-auto">
<h1 className="text-lg font-bold mb-3">{output.title}</h1>
<ul className="space-y-2">
{output.items.map((item) => {
const done = state.checked.includes(item.id);
return (
<li key={item.id}>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={done}
onChange={() => toggle(item.id)}
className="w-4 h-4 rounded"
/>
<span className={done ? 'line-through text-gray-400' : ''}>{item.label}</span>
</label>
</li>
);
})}
</ul>
<p className="text-xs text-gray-400 mt-4">
{state.checked.length} of {output.items.length} complete
</p>
</SafeArea>
);
}
Every time the user checks or unchecks an item, the model receives an updated checked array. The model can say “You’ve finished 3 of 5 steps, want to continue?” based on the actual state.
Adding Test Data
Add a simulation file at tests/simulations/request-approval.json:
{
"tool": "request-approval",
"userMessage": "Delete the old production snapshots",
"toolInput": {
"title": "Delete production snapshots",
"description": "This will permanently delete 14 snapshots from January 2025. This cannot be undone.",
"confirmLabel": "Delete",
"cancelLabel": "Keep them"
},
"toolResult": {
"structuredContent": {
"title": "Delete production snapshots",
"description": "This will permanently delete 14 snapshots from January 2025. This cannot be undone.",
"confirmLabel": "Delete",
"cancelLabel": "Keep them"
}
}
}
Run pnpm dev and open http://localhost:3000. The approval card renders. Click “Delete” and watch the state update to the confirmed view. The sunpeak Inspector shows the current useAppState value in the sidebar so you can verify what the model sees.
Testing Interactive State
For unit tests, use Vitest and @testing-library/react to test state transitions. Mock useAppState alongside useToolData:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ApprovalResource } from './approval';
let currentState = { decision: null, decidedAt: null };
const mockSetState = vi.fn((newState) => {
currentState = newState;
});
vi.mock('sunpeak', () => ({
useToolData: () => ({
output: {
title: 'Delete production snapshots',
description: 'This will permanently delete 14 snapshots.',
confirmLabel: 'Delete',
cancelLabel: 'Keep them',
},
input: null,
inputPartial: null,
isError: false,
isLoading: false,
isCancelled: false,
cancelReason: null,
}),
useAppState: () => [currentState, mockSetState],
useHostContext: () => null,
useDisplayMode: () => 'inline',
SafeArea: ({ children, ...props }: any) => <div {...props}>{children}</div>,
}));
describe('ApprovalResource', () => {
it('calls setState with confirmed decision on confirm click', () => {
render(<ApprovalResource />);
fireEvent.click(screen.getByText('Delete'));
expect(mockSetState).toHaveBeenCalledWith(
expect.objectContaining({ decision: 'confirmed' })
);
});
it('calls setState with cancelled decision on cancel click', () => {
currentState = { decision: null, decidedAt: null };
render(<ApprovalResource />);
fireEvent.click(screen.getByText('Keep them'));
expect(mockSetState).toHaveBeenCalledWith(
expect.objectContaining({ decision: 'cancelled' })
);
});
});
That test checks the component contract: a user click calls the state setter with the expected decision. It does not prove that the host bridge received the state. Add an inspector test for that.
For end-to-end tests, click the button inside the inspector iframe and assert that the confirmed state renders:
import { test, expect } from 'sunpeak/test';
test('approval resource shows confirmed state after clicking confirm', async ({ inspector }) => {
const result = await inspector.renderTool('request-approval', {});
const app = result.app();
await app.locator('text=Delete').click();
await expect(app.locator('text=Confirmed')).toBeVisible();
});
Then add one assertion against the model-visible state your inspector exposes. The exact selector depends on your inspector version and test helper, but the behavior should be explicit:
await expect(result.modelContext()).resolves.toEqual(
expect.objectContaining({
decision: 'confirmed',
}),
);
The same test can run against the Claude host by setting host: 'claude' in your Playwright project config. The sunpeak testing framework runs both host runtimes locally without accounts, API keys, or credits on your CI runners. See the cross-host testing guide for full matrix testing patterns.
For a production app, cover four states:
- Initial render from
structuredContent. - User interaction updates visible UI.
- User interaction updates model-visible app state.
- A new tool invocation starts from the intended initial state or from server data, not from stale UI state.
Where State Lives After the Decision
Once the user acts, useAppState holds the state for that resource instance and syncs the model-visible part to the host. Treat that as conversation state, not durable storage.
This is the right behavior for most interactive apps. An approval flow should start fresh each time the model asks for approval. A checklist that persists across conversations needs durable storage, usually your database or an MCP tool call that writes the user’s progress. On the next invocation, pass that saved state back as tool input or structuredContent.
On ChatGPT, widget state is scoped to the widget instance in a specific message. Reopening the same message can restore widget-scoped UI state, but a new model response creates a new widget instance. MCP Apps-compatible hosts use the same broad idea: app state belongs to the rendered resource, while business state belongs on the server.
Avoid localStorage for core state. It can be unavailable, isolated by sandbox policy, hard to clear, and inconsistent across hosts. It is fine for small UI preferences if the app can work without them.
When Not to Use useAppState
useAppState is for state the model needs to see. If the state only affects visual presentation and the model does not need to know about it, useState is simpler.
Display mode changes go through the display-mode request API, not useAppState. To request fullscreen:
import { useRequestDisplayMode } from 'sunpeak';
const { requestDisplayMode, availableModes } = useRequestDisplayMode();
if (availableModes?.includes('fullscreen')) {
requestDisplayMode({ mode: 'fullscreen' });
}
Read-only displays that adapt to host context use useHostContext and useDisplayMode, neither of which write state.
ChatGPT also has host-specific hooks for platform features: useUploadFile for adding files to the conversation, useRequestModal for host-controlled modals, and useRequestCheckout for payments. These are separate from useAppState because they trigger host actions rather than syncing state to the model. Guard them with capability detection or isChatGPT() since they only work on ChatGPT. See the ChatGPT Apps compatibility guide and the display mode reference for details on host-specific behavior.
The right mental model: useAppState is the outbound channel from user to model. useToolData is the inbound channel from model to user. Together they cover the full interaction loop for any MCP App.
Get Started
npx sunpeak newFurther Reading
- MCP App model context - what the AI can see after UI clicks
- MCP App actions - callServerTool, sendMessage, and updateModelContext
- MCP App state persistence - useAppState, widgetState, localStorage, and databases
- Testing MCP App data flow - content, structuredContent, _meta, and host bridge state
- useAppState hook reference - full API documentation
- sunpeak MCP Apps introduction - protocol architecture and runtime capabilities
- OpenAI Apps SDK state management - ui/update-model-context and widget state
- Official MCP Apps overview - resources, iframes, and host bridge
Frequently Asked Questions
What is useAppState in sunpeak?
useAppState is a React hook from sunpeak for MCP App state that should be visible to the AI model. It works like React useState, but each update is synced to the host through updateModelContext so the model can read user decisions, selections, staged form values, or checklist progress on a later turn.
What is the difference between useState and useAppState in MCP Apps?
React useState is local UI state. The host and model do not see it. useAppState is model-visible app state. Use useState for visual state such as expanded panels, hover effects, active tabs, and local animation. Use useAppState for state the model should use when it answers next, such as approvals, filters, selected rows, staged edits, and form progress.
How does the AI model read useAppState?
When state changes, useAppState calls updateModelContext with structured state. The host stores that context and can include it in the next model turn. It does not force an immediate assistant response. If you need the UI to actively ask the model to respond, use sendMessage or another conversation action instead.
What does the useAppState API look like?
useAppState works like useState but takes an initial state object and returns a readonly [state, setState] tuple. Example: const [state, setState] = useAppState({ confirmed: false }). The setState function accepts either a full replacement object or a callback function like setState(prev => ({ ...prev, confirmed: true })). Import it from sunpeak alongside useToolData.
Does useAppState work the same on ChatGPT and Claude?
Yes for portable state. useAppState is part of the core sunpeak import, so the resource component does not need ChatGPT-only or Claude-only code for model-visible state. ChatGPT also exposes optional window.openai widget-state APIs for ChatGPT-specific UI persistence, but new cross-host MCP Apps should treat ui/update-model-context as the portable model-visible state path.
How do I test interactive MCP App state with sunpeak?
Use Vitest with @testing-library/react to test component state transitions and mock useAppState alongside useToolData. Then run an inspector E2E test that renders the resource in a host replica, clicks the UI, and asserts both the visible state and the model-visible state payload. Add a cross-host matrix when the app must work in ChatGPT and Claude.
What are common use cases for useAppState in MCP Apps?
Common use cases include approval flows, multi-step forms, preference selectors, selected table rows, checklist progress, active filters, draft edits, and user confirmations before write actions. The test is simple: if the model should adjust its next answer based on the state, use useAppState.
Can I use useAppState and useToolData together in the same component?
Yes. useToolData reads the tool input and structuredContent returned by the MCP tool. useAppState manages state the user changes after the resource renders. Together they cover the normal two-way MCP App loop: model calls a tool, the resource renders the result, the user interacts with the UI, and the model can read the updated app state on a later turn.