Headless React state and streaming primitives for OpenUI chat experiences. Bring your own UI; this package handles threads, messages, adapters, and message format conversion.
Links: Package docs | Chat docs | GitHub repo
npm install @openuidev/react-headless
# or
pnpm add @openuidev/react-headlessPeer dependencies: react >=19.0.0, react-dom >=19.0.0, zustand ^4.5.5
The Vercel AI SDK integration has one optional peer dependency:
npm install ai@^6Use @openuidev/react-headless when you want OpenUI's chat behavior without OpenUI's visual components:
ChatProvidermanages threads, messages, and streaming state through a Zustand store.- Selector hooks expose thread and thread-list state without coupling you to a layout.
- Streaming adapters parse SSE or SDK responses from OpenAI, Vercel AI SDK, AG-UI, or custom backends.
- Message formats convert between your API shape and OpenUI's internal AG-UI shape.
The simplest configuration points to your API and lets the provider handle the requests and streaming automatically:
import { agUIAdapter, ChatProvider, fetchLLM, restStorage } from "@openuidev/react-headless";
const llm = fetchLLM({ url: "/api/chat", streamAdapter: agUIAdapter() });
const storage = restStorage({ baseUrl: "/api/threads" });
function App() {
return (
<ChatProvider llm={llm} storage={storage}>
<YourChatUI />
</ChatProvider>
);
}For full control, implement the ChatLLM interface instead:
import { openAIAdapter, openAIMessageFormat, type ChatLLM } from "@openuidev/react-headless";
const llm: ChatLLM = {
send: ({ threadId, messages, signal }) =>
fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ threadId, messages: openAIMessageFormat.toApi(messages) }),
signal,
}),
streamProtocol: openAIAdapter(),
};Access the current thread's messages, send new messages, and check streaming state:
import { useThread } from "@openuidev/react-headless";
function ChatMessages() {
const { messages, isRunning, processMessage, cancelMessage } = useThread();
const handleSend = (text: string) => {
processMessage({ role: "user", content: text });
};
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>{msg.content}</div>
))}
{isRunning && <button onClick={cancelMessage}>Stop</button>}
</div>
);
}Returns: ThreadState & ThreadActions
| Field | Type | Description |
|---|---|---|
messages |
Message[] |
Messages in the current thread |
isRunning |
boolean |
Whether the model is currently streaming |
isLoadingMessages |
boolean |
Whether messages are being fetched |
threadError |
Error | null |
Error from the last operation |
processMessage(msg) |
(msg) => Promise<void> |
Send a message and stream the response |
cancelMessage() |
() => void |
Abort the current stream |
appendMessages(...msgs) |
(...msgs) => void |
Append messages locally |
updateMessage(msg) |
(msg) => void |
Update a message in place |
deleteMessage(id) |
(id) => void |
Remove a message |
setMessages(msgs) |
(msgs) => void |
Replace all messages |
Manage multiple conversation threads:
import { useThreadList } from "@openuidev/react-headless";
function ThreadSidebar() {
const { threads, selectedThreadId, selectThread, switchToNewThread, deleteThread } =
useThreadList();
return (
<nav>
<button onClick={switchToNewThread}>New Chat</button>
{threads.map((t) => (
<div key={t.id} onClick={() => selectThread(t.id)}>
{t.title}
<button onClick={() => deleteThread(t.id)}>Delete</button>
</div>
))}
</nav>
);
}Returns: ThreadListState & ThreadListActions
| Field | Type | Description |
|---|---|---|
threads |
Thread[] |
All loaded threads |
selectedThreadId |
string | null |
Currently selected thread |
isLoadingThreads |
boolean |
Whether the thread list is loading |
hasMoreThreads |
boolean |
Whether more threads can be loaded |
loadThreads() |
() => void |
Fetch the thread list |
loadMoreThreads() |
() => void |
Load the next page of threads |
selectThread(id) |
(id) => void |
Select a thread |
switchToNewThread() |
() => void |
Deselect and start a new conversation |
createThread(msg) |
(msg) => Promise<Thread> |
Create a thread with a first message |
updateThread(thread) |
(thread) => void |
Update thread metadata |
deleteThread(id) |
(id) => void |
Delete a thread |
Access the current message inside a message component:
import { useMessage } from "@openuidev/react-headless";
function MessageBubble() {
const { message } = useMessage();
return <div className="bubble">{message.content}</div>;
}Adapters transform HTTP responses into the internal event stream. They are factories — call one and pass the result to fetchLLM via streamAdapter:
import { fetchLLM, openAIAdapter } from "@openuidev/react-headless";
const llm = fetchLLM({ url: "/api/chat", streamAdapter: openAIAdapter() });| Adapter | Description |
|---|---|
agUIAdapter() |
Parses AG-UI SSE events (data: {json}\n) |
openAIAdapter() |
Parses OpenAI Chat Completions streaming (ChatCompletionChunk) |
openAIResponsesAdapter() |
Parses OpenAI Responses API streaming (ResponseStreamEvent) |
openAIReadableStreamAdapter() |
Parses OpenAI SDK's Stream.toReadableStream() NDJSON output |
vercelAIAdapter() |
Parses Vercel AI SDK v6 UIMessage streams from toUIMessageStreamResponse() |
For a Vercel AI SDK route, use its stream adapter and message format together:
import { fetchLLM, vercelAIAdapter, vercelAIMessageFormat } from "@openuidev/react-headless";
const llm = fetchLLM({
url: "/api/chat",
streamAdapter: vercelAIAdapter(),
messageFormat: vercelAIMessageFormat,
});This integration supports app-executed tools. Provider-executed tools
(providerExecuted: true, such as provider-hosted built-ins) throw an error because
the AG-UI message model cannot preserve their assistant-contained result semantics.
Implement the StreamProtocolAdapter interface:
import type { StreamProtocolAdapter, AGUIEvent } from "@openuidev/react-headless";
const myAdapter: StreamProtocolAdapter = {
async *parse(response: Response): AsyncIterable<AGUIEvent> {
// parse the response stream and yield AGUIEvent objects
},
};Message formats convert between your API's message shape and the internal AG-UI format. Pass one to fetchLLM via the messageFormat option:
import { fetchLLM, openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless";
const llm = fetchLLM({
url: "/api/chat",
streamAdapter: openAIAdapter(),
messageFormat: openAIMessageFormat,
});| Format | Description |
|---|---|
identityMessageFormat |
Default format when messages are already AG-UI shaped |
openAIMessageFormat |
Converts to/from OpenAI ChatCompletionMessageParam[] |
openAIConversationMessageFormat |
Converts to/from OpenAI Responses API ResponseInputItem[] |
vercelAIMessageFormat |
Converts to/from Vercel AI SDK v6 UIMessage[] |
Implement the MessageFormat interface:
import type { MessageFormat } from "@openuidev/react-headless";
const myFormat: MessageFormat = {
toApi: (messages) => messages.map(convertToMyFormat),
fromApi: (data) => data as Message[],
};import type {
ChatProviderProps,
ChatStore,
Thread,
ThreadState,
ThreadActions,
ThreadListState,
ThreadListActions,
CreateMessage,
Message,
UserMessage,
AssistantMessage,
SystemMessage,
ToolMessage,
ToolCall,
FunctionCall,
MessageFormat,
StreamProtocolAdapter,
AGUIEvent,
EventType,
} from "@openuidev/react-headless";