OpenAI globals hooks
+Subscribe to ChatGPT host values like layout and widget state
+
+## Overview
+ChatGPT injects a `window.openai` object that includes layout details, locale, safe area, tool IO, and widget state. The hooks below subscribe to `openai:set_globals` events so your UI stays in sync with host updates. Until the host provides data they return `null`, so add sensible fallbacks for local development. See the [ChatGPT UI guide](https://developers.openai.com/apps-sdk/build/chatgpt-ui#managing-state) for the full host API.
+
+## State placement
+- **Business data (authoritative)** – lives on your MCP server or backend; return fresh snapshots via tools.
+- **Widget UI state (ephemeral)** – lives in `window.openai.widgetState` and should be updated through `useWidgetState` after every meaningful UI change.
+- **Cross-session state (durable)** – store in your backend if it must survive across conversations or devices.
+
+## `useOpenAiGlobal`
+Base hook for reading any key from `window.openai` with live updates. Pass the key you want and receive a typed value.
+
+```tsx
+import { useOpenAiGlobal } from "@openai/apps-sdk-ui/hooks/useOpenaiGlobal"
+
+function SafeAreaAwarePanel() {
+ const safeArea = useOpenAiGlobal("safeArea")
+ const locale = useOpenAiGlobal("locale")
+
+ return (
+
+
Current locale: {locale ?? "loading..."}
+
+ )
+}
+```
+
+## Layout hooks
+### `useDisplayMode`
+Returns the current display mode (`pip`, `inline`, or `fullscreen`) granted by the host. Useful for adjusting density or controls when PiP or fullscreen is active.
+
+### `useMaxHeight`
+Returns the available vertical space in pixels for the widget, or `null` while loading. Combine with overflow styles to keep content scrollable.
+
+```tsx
+import { useDisplayMode } from "@openai/apps-sdk-ui/hooks/useDisplayMode"
+import { useMaxHeight } from "@openai/apps-sdk-ui/hooks/useMaxHeight"
+
+function WidgetFrame({ children }: { children: React.ReactNode }) {
+ const displayMode = useDisplayMode()
+ const maxHeight = useMaxHeight()
+
+ return (
+
+ {children}
+
+ )
+}
+```
+
+## Widget data
+### `useWidgetProps`
+Reads the latest `toolOutput` provided by the host and falls back to an optional default (value or lazy initializer) when developing outside ChatGPT.
+
+```tsx
+import { useWidgetProps } from "@openai/apps-sdk-ui/hooks/useWidgetProps"
+
+type ReservationProps = { venue: string; time: string }
+
+const props = useWidgetProps(() => ({ venue: "Loading", time: "" }))
+```
+
+### `useWidgetState`
+Synchronizes widget UI state with ChatGPT. The hook hydrates from `window.openai.widgetState` when the widget mounts (or falls back to your initializer), subscribes to host changes, and mirrors writes through `window.openai.setWidgetState` so the host persists the snapshot for this widget instance. State is message-scoped and survives re-opens of the same widget but not new runs.
+
+```tsx
+import { useWidgetState } from "@openai/apps-sdk-ui/hooks/useWidgetState"
+
+type Filters = { cuisine: string | null; guests: number }
+
+function FiltersPanel() {
+ const [filters, setFilters] = useWidgetState(() => ({ cuisine: null, guests: 2 }))
+
+ return (
+
+
+
+
+ )
+}
+```
+
+All hooks return `null` until `window.openai` is available. Provide defaults where appropriate so your components render gracefully in non-ChatGPT environments.
diff --git a/src/hooks/useDisplayMode.ts b/src/hooks/useDisplayMode.ts
new file mode 100644
index 0000000..26fe03a
--- /dev/null
+++ b/src/hooks/useDisplayMode.ts
@@ -0,0 +1,6 @@
+import { type DisplayMode } from "../types"
+import { useOpenAiGlobal } from "./useOpenaiGlobal"
+
+export const useDisplayMode = (): DisplayMode | null => {
+ return useOpenAiGlobal("displayMode")
+}
diff --git a/src/hooks/useMaxHeight.ts b/src/hooks/useMaxHeight.ts
new file mode 100644
index 0000000..2a412e2
--- /dev/null
+++ b/src/hooks/useMaxHeight.ts
@@ -0,0 +1,5 @@
+import { useOpenAiGlobal } from "./useOpenaiGlobal"
+
+export const useMaxHeight = (): number | null => {
+ return useOpenAiGlobal("maxHeight")
+}
diff --git a/src/hooks/useOpenaiGlobal.ts b/src/hooks/useOpenaiGlobal.ts
new file mode 100644
index 0000000..e965b47
--- /dev/null
+++ b/src/hooks/useOpenaiGlobal.ts
@@ -0,0 +1,32 @@
+import { useSyncExternalStore } from "react"
+import { SET_GLOBALS_EVENT_TYPE, SetGlobalsEvent, type OpenAiGlobals } from "../types"
+
+export function useOpenAiGlobal(key: K): OpenAiGlobals[K] | null {
+ return useSyncExternalStore(
+ (onChange) => {
+ if (typeof window === "undefined") {
+ return () => {}
+ }
+
+ const handleSetGlobal: EventListener = (event) => {
+ const { globals } = (event as SetGlobalsEvent).detail
+ const value = globals[key]
+ if (value === undefined) {
+ return
+ }
+
+ onChange()
+ }
+
+ window.addEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal, {
+ passive: true,
+ })
+
+ return () => {
+ window.removeEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal)
+ }
+ },
+ () => window.openai?.[key] ?? null,
+ () => window.openai?.[key] ?? null,
+ )
+}
diff --git a/src/hooks/useWidgetProps.ts b/src/hooks/useWidgetProps.ts
new file mode 100644
index 0000000..4d84e20
--- /dev/null
+++ b/src/hooks/useWidgetProps.ts
@@ -0,0 +1,10 @@
+import { useOpenAiGlobal } from "./useOpenaiGlobal"
+
+export function useWidgetProps>(defaultState?: T | (() => T)): T {
+ const props = useOpenAiGlobal("toolOutput") as T
+
+ const fallback =
+ typeof defaultState === "function" ? (defaultState as () => T | null)() : defaultState ?? null
+
+ return props ?? fallback
+}
diff --git a/src/hooks/useWidgetState.ts b/src/hooks/useWidgetState.ts
new file mode 100644
index 0000000..4a5d424
--- /dev/null
+++ b/src/hooks/useWidgetState.ts
@@ -0,0 +1,45 @@
+import { useCallback, useEffect, useState, type SetStateAction } from "react"
+import type { UnknownObject } from "../types"
+import { useOpenAiGlobal } from "./useOpenaiGlobal"
+
+export function useWidgetState(
+ defaultState: T | (() => T),
+): readonly [T, (state: SetStateAction) => void]
+export function useWidgetState(
+ defaultState?: T | (() => T | null) | null,
+): readonly [T | null, (state: SetStateAction) => void]
+export function useWidgetState(
+ defaultState?: T | (() => T | null) | null,
+): readonly [T | null, (state: SetStateAction) => void] {
+ const widgetStateFromWindow = useOpenAiGlobal("widgetState") as T
+
+ const [widgetState, _setWidgetState] = useState(() => {
+ if (widgetStateFromWindow != null) {
+ return widgetStateFromWindow
+ }
+
+ return typeof defaultState === "function" ? defaultState() : defaultState ?? null
+ })
+
+ useEffect(() => {
+ _setWidgetState(widgetStateFromWindow)
+ }, [widgetStateFromWindow])
+
+ const setWidgetState = useCallback(
+ (state: SetStateAction) => {
+ _setWidgetState((prevState) => {
+ const newState = typeof state === "function" ? state(prevState) : state
+
+ if (newState != null) {
+ window.openai.setWidgetState(newState)
+ }
+
+ return newState
+ })
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [window.openai.setWidgetState],
+ )
+
+ return [widgetState, setWidgetState] as const
+}
diff --git a/src/types.ts b/src/types.ts
index b80b8c0..891c88e 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -46,3 +46,89 @@ export type Alignments = T
export type FontWeight = "inherit" | "normal" | "medium" | "semibold" | "bold"
export type FontWeights = T
+
+export type OpenAiGlobals<
+ ToolInput = UnknownObject,
+ ToolOutput = UnknownObject,
+ ToolResponseMetadata = UnknownObject,
+ WidgetState = UnknownObject,
+> = {
+ // visuals
+ theme: Theme
+
+ userAgent: UserAgent
+ locale: string
+
+ // layout
+ maxHeight: number
+ displayMode: DisplayMode
+ safeArea: SafeArea
+
+ // state
+ toolInput: ToolInput
+ toolOutput: ToolOutput | null
+ toolResponseMetadata: ToolResponseMetadata | null
+ widgetState: WidgetState | null
+ setWidgetState: (state: WidgetState) => Promise
+}
+
+export type API = {
+ callTool: CallTool
+ sendFollowUpMessage: (args: { prompt: string }) => Promise
+ openExternal(payload: { href: string }): void
+
+ // Layout controls
+ requestDisplayMode: RequestDisplayMode
+ requestModal: (args: { title?: string; params?: UnknownObject }) => Promise
+ requestClose: () => Promise
+}
+
+export type UnknownObject = Record
+
+export type Theme = "light" | "dark"
+
+export type SafeAreaInsets = {
+ top: number
+ bottom: number
+ left: number
+ right: number
+}
+
+export type SafeArea = {
+ insets: SafeAreaInsets
+}
+
+export type DeviceType = "mobile" | "tablet" | "desktop" | "unknown"
+
+export type UserAgent = {
+ device: { type: DeviceType }
+ capabilities: {
+ hover: boolean
+ touch: boolean
+ }
+}
+
+/** Display mode */
+export type DisplayMode = "pip" | "inline" | "fullscreen"
+export type RequestDisplayMode = (args: { mode: DisplayMode }) => Promise<{
+ /**
+ * The granted display mode. The host may reject the request.
+ * For mobile, PiP is always coerced to fullscreen.
+ */
+ mode: DisplayMode
+}>
+
+export type CallToolResponse = {
+ result: string
+}
+
+/** Calling APIs */
+export type CallTool = (name: string, args: Record) => Promise
+
+/** Extra events */
+export const SET_GLOBALS_EVENT_TYPE = "openai:set_globals"
+export class SetGlobalsEvent extends CustomEvent<{
+ globals: Partial
+}> {
+ readonly type = SET_GLOBALS_EVENT_TYPE
+}