1. Por que pensar no estado do widget
Em um aplicativo React comum você está acostado: existe estado local, existem chamadas de API e, no máximo, algum Zustand/Redux. Tudo gira ao redor do navegador do usuário.
No ChatGPT App a situação é diferente. Seu widget é apenas uma camada de UI leve sobre outras três entidades:
- o modelo do ChatGPT, que decide quando chamar seu App e quais argumentos passar;
- o servidor MCP/backend, que armazena os dados reais e executa a lógica de negócios;
- o contexto do chat, no qual tudo isso vive e pode ser reaberto em uma hora, um dia ou uma semana.
Portanto, “onde está o estado” não é uma pergunta acadêmica, mas muito prática. Se você colocar tudo apenas no estado do React, ao menor movimento no chat o usuário perderá a seleção. Se você enfiar tudo em widgetState, o modelo começará a ler toneladas de JSON e a alucinar sobre isso. Se, ao contrário, tentar guardar tudo no servidor e requisitar cada pixel de novo — ficará lento e caro.
As recomendações oficiais dividem claramente o estado do ChatGPT App em três classes: dados de negócios, estado de UI efêmero e estado durável entre sessões. É por aí que vamos começar.
2. Mapa de estados no ChatGPT App
A documentação do Apps SDK descreve três tipos de estado. É útil mantê-los em mente como uma única tabela:
| Tipo de estado | Onde vive | Ciclo de vida | Exemplos |
|---|---|---|---|
| Business data (authoritative) | Servidor MCP / seu backend | Longo: dias, semanas, anos | tarefas, pedidos, produtos |
| UI state (ephemeral) | Dentro do widget específico | Enquanto o widget específico estiver montado | card selecionado, ordenação, seção expandida |
| Cross‑session state (durable) | Seu backend / armazenamento | Entre sessões e chats | filtros salvos, workspace, pinned board |
Importante: dados authoritative devem permanecer no servidor, não no widget. O widget recebe um snapshot desses dados via ferramentas (MCP tools) e os renderiza, aplicando seu estado de UI local sobre eles.
Nesta aula, nosso foco é no que o widget vê:
- toolInput — argumentos de entrada da ferramenta chamada;
- toolOutput — structuredContent do servidor (os dados principais);
- toolResponseMetadata — metadados de serviço _meta, visíveis somente para o widget;
- widgetState — estado de UI salvo, que o ChatGPT armazena junto com a mensagem.
3. O que exatamente chega ao widget: ToolInput, ToolOutput, Metadata, WidgetState
Esses três tipos de estado no ChatGPT App se refletem em campos concretos que a plataforma coloca em window.openai e injeta nos hooks do SDK. Na prática, você os obterá via hooks do React, mas é útil conhecer as definições exatas.
toolInput
É um objeto com os argumentos da ferramenta (tool) que o modelo passou ao chamá-la.
Por exemplo, o usuário escreve:
“Escolha ideias de presentes para uma mulher de 30 anos, orçamento de 100 dólares.”
O modelo decide chamar sua ferramenta gift_search com os argumentos:
{
"recipient": "female",
"age": 30,
"budget": 100,
"occasion": "birthday"
}
É exatamente esse objeto que você verá em toolInput dentro do widget. Ali ficam as configurações iniciais do cenário — aquilo pelo que seu App foi acionado.
toolOutput
É o structuredContent retornado pelo seu servidor MCP / backend ao executar a ferramenta.
Normalmente é um JSON como:
{
"gifts": [
{ "id": "1", "title": "Guia de viagem da Islândia", "price": 45 },
{ "id": "2", "title": "E-book sobre viagens", "price": 20 }
],
"total": 2
}
toolOutput é a principal fonte de dados para renderização. A documentação enfatiza: o modelo lê esse campo literalmente, portanto mantenha-o compacto e claro.
toolResponseMetadata
São os _meta da resposta da ferramenta, também disponíveis via window.openai como toolResponseMetadata. A documentação destaca que o conteúdo de _meta é visto apenas pelo widget; o modelo não o recebe.
Exemplos típicos:
- IDs internos do seu sistema;
- flags de UI (por exemplo, “houve cache?”);
- mensagens auxiliares para depuração.
Em resumo: toolOutput é “o que dizer ao usuário e ao modelo”, e _meta é “o que só o widget e os logs precisam”.
widgetState
É um objeto JSON em que o ChatGPT guarda um snapshot do estado de UI do widget específico entre renderizações.
Suas propriedades:
- vive no lado do ChatGPT e é vinculado a um message/widgetId específico;
- é restaurado quando essa mesma mensagem é reaberta;
- é visível tanto para o widget quanto para o modelo (os dados de widgetState entram no contexto da LLM);
- é limitado em tamanho a aproximadamente 4k tokens, então não jogue tudo lá dentro nem armazene listas enormes.
Importante: widgetState não é lugar para segredos. Nem tokens nem dados pessoais (PII) devem ser colocados ali, porque o modelo os verá e a própria plataforma não posiciona isso como um armazenamento seguro.
4. Estado local do React: onde ele ainda é necessário
Apesar de toda a mágica ao redor de toolOutput e widgetState, dentro do widget você ainda escreve React normal com useState, useReducer, useRef etc. A diferença é apenas que:
- o estado local vive enquanto existir a renderização/iframe atual;
- o modelo não o vê de forma alguma;
- ao desmontar o widget (o usuário foi para outro chat, houve re-render, atualização), o estado local desaparece.
O estado local é ótimo para:
- coisas instantâneas — hover, aba selecionada, dropdown aberto;
- preenchimento de formulário antes de clicar em “Continuar”/“Salvar”;
- flags temporários como isSubmitting ou isTooltipOpen.
Mini‑exemplo no nosso app didático GiftGenius — um assistente de escolha de presentes:
const [selectedGiftId, setSelectedGiftId] = useState<string | null>(null);
return (
<div>
{gifts.map(gift => (
<button
key={gift.id}
onClick={() => setSelectedGiftId(gift.id)}
>
{gift.title}
</button>
))}
</div>
);
Enquanto não clicarmos em “Confirmar a escolha”, este é um excelente candidato a estado local. Mas assim que quisermos que a escolha “sobreviva” entre atualizações do widget, devemos pensar em widgetState.
5. widgetState: a memória do widget entre renderizações
widgetState é a “memória” do widget que a própria plataforma salva. A cada ação importante de UI você pode chamar setWidgetState, e o ChatGPT salvará esse JSON junto com a mensagem. Na próxima renderização do mesmo widget (por exemplo, o usuário rolou o histórico do chat para trás e depois voltou) o SDK restaurará esse objeto e o entregará a você.
Rigorosamente falando, seria possível chamar diretamente window.openai.widgetState e window.openai.setWidgetState, mas nesta aula seguimos o caminho recomendado — hooks do React na camada do SDK.
Hook useWidgetState
Um desses hooks justamente encapsula o widgetState. Ele:
- pega o valor inicial de window.openai.widgetState ou do defaultState fornecido;
- assina para receber atualizações do host;
- a cada setWidgetState seu, sincroniza o novo valor para cima via window.openai.setWidgetState.
Exemplo de uso típico dentro do componente do widget (a sintaxe pode diferir um pouco no template, mas a ideia é essa):
import { useWidgetState } from "@openai/chatgpt-apps-sdk/react";
type GiftUiState = { likedIds: string[] };
const [uiState, setUiState] = useWidgetState<GiftUiState>(() => ({
likedIds: [],
}));
Agora o uiState será restaurado mesmo depois que o usuário:
- minimizar/expandir o chat;
- mudar para outro diálogo e voltar;
- atualizar a página (se a plataforma decidir restaurar esse widget).
Exemplo: lembrar o presente selecionado
Vamos pegar a lista de presentes do toolOutput e salvar o presente selecionado no widgetState, para que ele não se perca.
type Gift = { id: string; title: string; price: number };
const [uiState, setUiState] = useWidgetState<{ selectedId: string | null }>(() => ({
selectedId: null,
}));
return (
<ul>
{gifts.map(gift => (
<li
key={gift.id}
style={{
fontWeight: uiState?.selectedId === gift.id ? "bold" : "normal",
}}
onClick={() => setUiState({ selectedId: gift.id })}
>
{gift.title}
</li>
))}
</ul>
);
Aqui há um ponto importante: setUiState não apenas altera o estado local do React; ele também chama window.openai.setWidgetState por baixo dos panos, se estiver disponível.
Se o usuário depois clicar em um follow‑up sob este widget, o ChatGPT pode continuar a conversa com o mesmo widgetId e o mesmo widgetState, e o modelo verá qual presente foi escolhido.
6. Lendo os dados da ferramenta no React: useWidgetProps e análogos
Para que cada componente não mexa diretamente em window.openai.toolOutput, o Apps SDK traz mais uma camada útil — o hook useWidgetProps. Ele pega o toolOutput do global, oferece um objeto tipado e, se quiser, mistura valores padrão.
A assinatura simplificada é assim:
export function useWidgetProps<T>(defaultState?: T | () => T): T {
const toolOutput = useOpenAIGlobal("toolOutput") as T;
return toolOutput ?? defaultState ?? null;
}
Ou seja, você recebe toolOutput como o tipo T.
Suponha que nossa ferramenta MCP retorne este structuredContent:
type GiftToolOutput = {
gifts: { id: string; title: string; price: number }[];
currency: string;
};
O widget pode ler assim:
import { useWidgetProps } from "@openai/chatgpt-apps-sdk/react";
export function GiftListWidget() {
const { gifts, currency } = useWidgetProps<GiftToolOutput>(() => ({
gifts: [],
currency: "USD",
}));
if (!gifts.length) {
return <div>Ainda não há ideias adequadas. Tente outro pedido.</div>;
}
return (
<ul>
{gifts.map(gift => (
<li key={gift.id}>
{gift.title} — {gift.price} {currency}
</li>
))}
</ul>
);
}
Aqui temos várias boas práticas:
- não presumimos que toolOutput já exista — definimos um valor padrão;
- tratamos cuidadosamente a lista vazia;
- nada de acesso direto a window.openai — tudo via hook.
7. Sincronizando o UI com toolOutput: carregamento, vazio, erros
No mundo real, o toolOutput nem sempre chega instantaneamente e nem sempre “bonito”. A documentação do Apps SDK recomenda explicitamente pensar em três estados: carregando, dados normais, erro/vazio.
Padrão mais simples:
type GiftToolOutput = {
gifts: { id: string; title: string }[];
error?: string;
};
const data = useWidgetProps<GiftToolOutput | null>(() => null);
if (data === null) {
return <div>Carregando ideias de presentes…</div>;
}
if (data.error) {
return <div>Erro: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>Nada foi encontrado para seus critérios.</div>;
}
return (
<ul>
{data.gifts.map(gift => (
<li key={gift.id}>{gift.title}</li>
))}
</ul>
);
Essa abordagem combina bem com o fato de que o servidor e o modelo podem chamar a ferramenta novamente, e você receberá um novo toolOutput. O widget então apenas receberá o novo valor via useWidgetProps e será rerenderizado.
No fluxo geral, isso fica assim:
Usuário → pedido
↓
Modelo → chama a MCP tool
↓
Servidor → processa, acessa o banco de dados/integradores, retorna structuredContent e _meta
↓
ChatGPT → coloca structuredContent em toolOutput
↓
Widget → renderiza o UI a partir de toolOutput + widgetState
O guia oficial do servidor desenha quase o mesmo diagrama “User → Model → MCP tool → widget iframe”, onde toolOutput é a principal entrada para o widget.
8. Cenário multi‑passos: passo atual em widgetState
Nosso GiftGenius dificilmente se limitará a um único card. Na maioria das vezes queremos um “wizard” com passos: primeiro coletar preferências, depois definir o orçamento e, ao final, sugerir opções concretas.
Uma forma lógica de guardar o número do passo do wizard é no widgetState. É exatamente assim que a documentação e os exemplos recomendam.
Exemplo de mini‑wizard com dois passos:
type GiftWizardState = {
step: 1 | 2;
budget?: number;
};
const [state, setState] = useWidgetState<GiftWizardState>(() => ({ step: 1 }));
if (state.step === 1) {
return (
<div>
<label>
Orçamento, $
<input
type="number"
defaultValue={state.budget ?? 50}
onBlur={e =>
setState({ step: 2, budget: Number(e.target.value) || 50 })
}
/>
</label>
</div>
);
}
return (
<div>
<div>Procurando presentes até {state.budget} $…</div>
{/* aqui já poderíamos renderizar o toolOutput com os presentes */}
</div>
);
Pontos interessantes aqui:
- na primeira exibição step é 1, o usuário informa o orçamento;
- após o onBlur atualizamos o widgetState para { step: 2, budget: … };
- na próxima renderização (inclusive depois de um minuto ou ao reabrir esta mensagem) o widget já estará no passo 2 com o orçamento salvo.
Numa versão mais avançada, você no segundo passo já dispara a ferramenta via useCallTool, passa o budget e lê o resultado em toolOutput. Mas isso já remete ao módulo sobre ferramentas (Módulo 4); hoje o principal é onde mantemos a informação do passo.
9. Onde colocar o quê: o padrão “UI fino, backend robusto”
Vamos resumir a divisão de responsabilidades:
- dados autoritativos (lista de presentes, status de pedidos) vivem no servidor e chegam em toolOutput;
- itens visuais temporários (se a seção está expandida, conteúdo atual de uma entrada ainda não concluída) vivem no estado local do React;
- decisões de UI duráveis dentro de um widget (passo atual, item selecionado, ordenação) vivem em widgetState;
- configurações de longo prazo do usuário entre chats (categoria favorita de presentes, última moeda) vivem no seu backend como estado persistente.
Muitas vezes dá vontade de fazer um “objeto grandão com tudo”, colocá‑lo no widgetState e ficar tranquilo. Mas essa é uma má ideia. A documentação enfatiza que o estado que você passa via widgetState entra integralmente no contexto do modelo e deve ser leve e, em sua maior parte, sobre UI.
O mesmo vale para toolOutput: coloque ali exatamente os dados de que o widget e o modelo precisam para explicar ao usuário o que aconteceu. Árvores enormes, blobs binários, respostas cruas de outras APIs — tudo isso é caminho certo para respostas estranhas e caras do modelo.
Insight
Dentro do widget do ChatGPT é impossível depender de mecanismos clássicos de identificação do cliente. Cookies estão praticamente indisponíveis: o widget é carregado como recurso de terceiros na sandbox do ChatGPT, e os navegadores modernos bloqueiam cookies de terceiros por padrão. Por isso, quaisquer tentativas de salvar estado via cookie não funcionam.
Verificado experimentalmente: localStorage funciona muito bem; você pode contar com ele ao projetar seus aplicativos.
10. Pequeno exemplo ponta a ponta: GiftGenius com seleção durável
Vamos juntar tudo em um mini‑widget que:
- lê os dados do toolOutput;
- salva a seleção do usuário em widgetState;
- trata dados vazios com cuidado.
import {
useWidgetProps,
useWidgetState,
} from "@openai/chatgpt-apps-sdk/react";
type Gift = { id: string; title: string; price: number };
type GiftToolOutput = { gifts: Gift[]; currency: string; error?: string };
export function GiftWidget() {
const data = useWidgetProps<GiftToolOutput | null>(() => null);
const [uiState, setUiState] = useWidgetState<{ selectedId: string | null }>(
() => ({ selectedId: null })
);
if (data === null) {
return <div>Um segundo, estamos escolhendo ideias…</div>;
}
if (data.error) {
return <div>Erro: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>Infelizmente, não encontramos nada. Tente outro pedido.</div>;
}
return (
<ul>
{data.gifts.map(gift => (
<li
key={gift.id}
style={{
fontWeight: uiState?.selectedId === gift.id ? "bold" : "normal",
cursor: "pointer",
}}
onClick={() => setUiState({ selectedId: gift.id })}
>
{gift.title} — {gift.price} {data.currency}
</li>
))}
</ul>
);
}
Esse código já está bem próximo de um widget real:
- se a ferramenta ainda estiver em execução, vemos “estamos escolhendo ideias”;
- se o servidor retornar um erro — mostramos honestamente;
- se não houver presentes — tratamos corretamente o resultado vazio;
- o presente selecionado é lembrado no widgetState, e o modelo pode usá‑lo nos próximos passos do diálogo.
Depois você poderá adicionar botões “Continuar com este presente” (follow‑up), disparar novas ferramentas etc., contando com o fato de que a escolha já está no estado.
No fim, uma boa arquitetura de estado no ChatGPT App se resume a uma ideia simples: dados de negócios vivem no servidor, o snapshot atual chega via toolOutput, o UI temporário fica no useState local, e o contexto do widget, durável mas vinculado a uma única mensagem, fica em widgetState. Se você mantiver esse esquema em mente e não tentar enfiar “tudo de uma vez” em uma camada só, o widget permanece previsível tanto para o usuário quanto para o modelo.
11. Erros comuns ao trabalhar com Widget State, ToolInput e ToolOutput
Erro nº 1: Armazenar dados de negócios em widgetState em vez do servidor.
Às vezes dá vontade de salvar a lista inteira de entidades em widgetState para não chamar o servidor de novo. Isso é ruim por dois motivos: você duplica dados autoritativos (servidor e widget podem divergir) e incha o contexto do modelo, porque widgetState entra nele por completo. É melhor manter os dados reais no servidor e retornar um toolOutput fresco como snapshot.
Erro nº 2: Colocar no widgetState segredos ou PII.
Como o conteúdo do widgetState é visível ao modelo e não foi projetado como armazenamento protegido, não coloque ali tokens, logins, e‑mails, telefones e outras informações confidenciais. Essas coisas devem ficar no servidor, e no widgetState no máximo deve haver o ID do registro com o qual você trabalhará via MCP.
Erro nº 3: Achar que toolOutput sempre existe e sempre é correto.
Um widget que acessa toolOutput.gifts[0] sem checar vai quebrar mais cedo ou mais tarde: a ferramenta pode retornar erro, array vazio ou mudar a estrutura. Recomenda‑se tratar explicitamente os estados “carregando”, “vazio”, “erro” e só então renderizar normalmente.
Erro nº 4: Copiar toolOutput para o estado local sem necessidade.
É tentador fazer const [data, setData] = useState(toolOutput) e, a partir daí, viver só com esse data. O resultado é uma fonte duplicada da verdade: quando chegar um novo toolOutput, o estado local não ficará sabendo e o UI continuará mostrando dados antigos. É melhor ler o toolOutput diretamente de useWidgetProps ou produzir estado derivado (mapeamento, filtro) na renderização, sem duplicar o objeto inteiro.
Erro nº 5: Usar apenas o useState local onde é necessário widgetState.
Bug clássico: você faz um pequeno wizard, guarda currentStep no estado local, testa tudo — funciona. Depois o usuário rola o chat, volta — e de repente está no primeiro passo novamente. A razão é simples: o estado local não sobreviveu ao desmontar do widget. Para passos importantes do cenário, use widgetState; a plataforma então os restaurará junto com a mensagem.
Erro nº 6: Tentar acessar window.openai diretamente em cada componente.
Funciona formalmente, mas você cria forte acoplamento ao global, código difícil de depurar e assinaturas de eventos manuais. Os materiais e exemplos oficiais aconselham usar a camada de hooks (useWidgetProps, useWidgetState, useOpenAiGlobal), que encapsula os detalhes e é mais fácil de testar.
Erro nº 7: Ignorar a natureza com escopo de mensagem (message‑scoped) dos widgets.
Se o usuário não clica em follow‑up e apenas escreve uma nova mensagem no chat, o ChatGPT cria um novo instance do widget com um novo widgetId e widgetState vazio. Cenários que dependem de uma memória “eterna” de um único widget começam a se comportar de forma estranha. Aqui é necessário ou guardar o contexto entre sessões no servidor, ou construir o UX em torno de follow‑ups e continuação explícita do cenário.
GO TO FULL VERSION