diff --git a/docs/app/docs/chat/page.tsx b/docs/app/docs/chat/page.tsx
deleted file mode 100644
index 86243152e..000000000
--- a/docs/app/docs/chat/page.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import { Button } from "@/components/button";
-import {
- CodeBlock,
- FeatureCard,
- FeatureCards,
- Separator,
- SimpleCard,
-} from "@/components/overview-components";
-import {
- Code2,
- Database,
- Layout,
- Maximize2,
- MessageCircle,
- MessageSquare,
- PanelRightOpen,
- Zap,
- type LucideIcon,
-} from "lucide-react";
-import Link from "next/link";
-
-export const metadata = {
- title: "OpenUI Chat SDK",
- description:
- "Production-ready chat UI for AI agents. Drop-in layouts, streaming, and state management.",
-};
-
-const headlessCode = `import { useChat } from '@openuidev/react';
-
-function CustomChat() {
- const { messages, append, isLoading } = useChat();
-
- return (
-
- {messages.map(m => (
-
- {m.content}
-
- ))}
-
-
append(e.target.value)}
- />
-
- );
-}`;
-
-const layoutOptions = [
- {
- icon: ,
- title: "Copilot",
- description: "A sidebar assistant that lives alongside your main application content.",
- href: "/docs/chat/copilot",
- },
- {
- icon: ,
- title: "Full Screen",
- description: "A standalone, immersive chat page similar to ChatGPT or Claude.",
- href: "/docs/chat/fullscreen",
- },
- {
- icon: ,
- title: "Bottom Tray",
- description: "A floating support-style widget that expands from the bottom corner.",
- href: "/docs/chat/bottom-tray",
- },
-] as const;
-
-const capabilities = [
- {
- icon: ,
- title: "Streaming Native",
- description: "Handles text deltas, optimistic updates, loading states, and partial responses.",
- },
- {
- icon: ,
- title: "Thread Persistence",
- description: "Save and restore conversation history with straightforward API contracts.",
- },
- {
- icon: ,
- title: "Composable State",
- description: "Use the same primitives across prebuilt layouts and fully custom chat surfaces.",
- },
-] as const;
-
-function SectionHeader({
- icon: Icon,
- title,
- description,
-}: {
- icon: LucideIcon;
- title: string;
- description: string;
-}) {
- return (
-
-
-
-
-
-
{title}
-
{description}
-
-
- );
-}
-
-export default function ChatOverviewPage() {
- return (
-
-
- OpenUI Chat SDK
-
- Production-ready chat UI for AI agents. Start with prebuilt layouts for fast integration,
- then drop down to headless hooks when you need full control over behavior and rendering.
-
-
-
-
-
-
-
-
-
-
-
-
-
- {layoutOptions.map((item) => (
-
- ))}
-
-
-
-
-
-
-
-
-
-
- {capabilities.map((item) => (
-
- ))}
-
-
-
-
-
-
-
-
- The `useChat` hook gives you message state, append helpers, and loading semantics
- without locking you into a specific UI.
-
-
-
- Read the Headless Guide
-
-
-
-
-
- );
-}
diff --git a/docs/components/docs-navbar.tsx b/docs/components/docs-navbar.tsx
index c0efddf28..59ca8731e 100644
--- a/docs/components/docs-navbar.tsx
+++ b/docs/components/docs-navbar.tsx
@@ -12,16 +12,19 @@ import styles from "./docs-navbar.module.css";
import { SiteHeaderFrame } from "./site-header";
import { ThemeToggle } from "./theme-toggle";
-const tabs = [
+const tabs: { title: string; url: string; match?: string }[] = [
{ title: "OpenUI", url: "/docs/openui-lang" },
- { title: "Chat", url: "/docs/chat" },
+ { title: "Agent Interface", url: "/docs/agent/getting-started/introduction", match: "/docs/agent" },
{ title: "API Reference", url: "/docs/api-reference" },
-] as const;
+];
function activeTabUrl(pathname: string): string {
- const sorted = [...tabs].sort((a, b) => b.url.length - a.url.length);
+ const sorted = [...tabs].sort((a, b) => (b.match ?? b.url).length - (a.match ?? a.url).length);
return (
- sorted.find((t) => pathname === t.url || pathname.startsWith(`${t.url}/`))?.url ?? tabs[0].url
+ sorted.find((t) => {
+ const prefix = t.match ?? t.url;
+ return pathname === prefix || pathname.startsWith(`${prefix}/`);
+ })?.url ?? tabs[0].url
);
}
diff --git a/docs/components/overview-components/chat-modal.css b/docs/components/overview-components/chat-modal.css
index c98c21cfd..5fbd3cbbe 100644
--- a/docs/components/overview-components/chat-modal.css
+++ b/docs/components/overview-components/chat-modal.css
@@ -69,14 +69,14 @@
height: 100%;
}
-/* Override Shell container sizing to fit within the modal */
-.chat-modal-body .openui-shell-container {
+/* Override AgentInterface container sizing to fit within the modal */
+.chat-modal-body .openui-agent-container {
height: 100% !important;
width: 100% !important;
}
/* Hide the sidebar in the modal */
-.chat-modal-body .openui-shell-sidebar-container {
+.chat-modal-body .openui-agent-sidebar-container {
display: none !important;
}
diff --git a/docs/components/overview-components/chat-modal.tsx b/docs/components/overview-components/chat-modal.tsx
index 29934194f..f45cffbfb 100644
--- a/docs/components/overview-components/chat-modal.tsx
+++ b/docs/components/overview-components/chat-modal.tsx
@@ -8,12 +8,16 @@ import "./chat-modal.css";
import { DemoCreditsDialog } from "@/components/DemoCreditsDialog";
import { isDemoCreditsErrorPayload } from "@/lib/demo-credits";
-import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
+import {
+ AgentInterface,
+ openAIAdapter,
+ openAIMessageFormat,
+ type ChatLLM,
+} from "@openuidev/react-ui";
import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
import { X } from "lucide-react";
import { useTheme } from "next-themes";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
interface ChatModalProps {
@@ -40,6 +44,41 @@ export function ChatModal({ onClose }: ChatModalProps) {
};
}, [handleKey]);
+ // The backend call — including the demo-credits error handling — is unchanged;
+ // only the chat surface moved from FullScreen to AgentInterface. AgentInterface
+ // uses its built-in in-memory thread storage (wiped on reload).
+ const llm = useMemo(
+ () => ({
+ send: async ({ messages, signal }) => {
+ const response = await fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: openAIMessageFormat.toApi(messages),
+ }),
+ signal,
+ });
+
+ if (!response.ok) {
+ const err = await response
+ .clone()
+ .json()
+ .catch(() => ({}));
+ if (isDemoCreditsErrorPayload((err as { error?: unknown }).error)) {
+ setShowOverviewCreditsDialog(true);
+ return new Response("data: [DONE]\n\n", {
+ headers: { "Content-Type": "text/event-stream" },
+ });
+ }
+ }
+
+ return response;
+ },
+ streamProtocol: openAIAdapter(),
+ }),
+ [],
+ );
+
return createPortal(
e.stopPropagation()}>
@@ -47,63 +86,37 @@ export function ChatModal({ onClose }: ChatModalProps) {
-
{
- const response = await fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
-
- if (!response.ok) {
- const err = await response
- .clone()
- .json()
- .catch(() => ({}));
- if (isDemoCreditsErrorPayload((err as { error?: unknown }).error)) {
- setShowOverviewCreditsDialog(true);
- return new Response("data: [DONE]\n\n", {
- headers: { "Content-Type": "text/event-stream" },
- });
- }
- }
-
- return response;
- }}
- streamProtocol={openAIAdapter()}
+
+ starterVariant="short"
+ starters={[
+ {
+ displayText: "Revenue dashboard",
+ prompt:
+ "Build a revenue dashboard with a bar chart showing monthly revenue for Q4, key metrics, and a summary table.",
+ },
+ {
+ displayText: "Signup form",
+ prompt:
+ "Create a user registration form with name, email, password, and country fields with validation.",
+ },
+ {
+ displayText: "Compare React vs Vue",
+ prompt:
+ "Show me a comparison of React and Vue frameworks using tabs with pros, cons, and a feature comparison table.",
+ },
+ {
+ displayText: "Travel destinations",
+ prompt:
+ "Show me a carousel of 3 popular travel destinations with images, descriptions, and best time to visit.",
+ },
+ ]}
+ >
+
+
-
@@ -199,25 +198,26 @@ export function AssistantMessage({ content, isStreaming }) {
- Pre-built chat layouts (Copilot, Fullscreen, Bottom Tray) or build custom UIs with
- headless hooks. Fully themeable and accessible out of the box.
+ Drop in AgentInterface — a production-ready artifact chat surface with thread
+ history, conversation starters, and streaming — or build custom UIs with headless hooks.
+ Fully themeable and accessible out of the box.
@@ -254,25 +254,31 @@ export function AssistantMessage({ content, isStreaming }) {
+ fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ messages: openAIMessageFormat.toApi(messages) }),
+ signal,
+ }),
+ streamProtocol: openAIAdapter(),
+};
+
+
`}
/>
-
-
-
-
-
@@ -349,12 +355,28 @@ const customLibrary = createLibrary({
+ fetch('/api/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ messages: openAIMessageFormat.toApi(messages) }),
+ signal,
+ }),
+ streamProtocol: openAIAdapter(),
+};
function App() {
return (
-
);
diff --git a/docs/content/docs/agent/core-concepts/artifacts.mdx b/docs/content/docs/agent/core-concepts/artifacts.mdx
new file mode 100644
index 000000000..c9ec10d37
--- /dev/null
+++ b/docs/content/docs/agent/core-concepts/artifacts.mdx
@@ -0,0 +1,44 @@
+---
+title: Artifacts
+description: Durable, first-class conversation outputs like slides, reports, and apps that the user can open and return to.
+---
+
+An **artifact** is a first-class output of a conversation. Slides, reports, dashboards, a small app: things the user opens, reads, and returns to. An artifact is not a chat message and not a tool result. Once it exists, it stands on its own.
+
+On OpenUI Cloud, the agent produces **slides and reports** for you, with nothing to wire.
+
+## Where an artifact shows up
+
+The same renderer drives two surfaces:
+
+- **Preview:** a compact, inline view shown inside the chat message as the artifact is produced.
+- **Actual:** the full view, opened in a panel or on its own page.
+
+
+
+## Static vs live
+
+Static and live describe how an artifact behaves, not two different APIs.
+
+**Static** content is frozen at generation. A report, a slide deck, generated code, a document. Re-open it next week and you see exactly what was produced.
+
+**Live** content is data-backed and interactive. A dashboard re-fetches current numbers on open. A small app exposes filters and controls and can write edits back.
+
+If the user expects a fixed record, it is static. If they expect fresh data, it is live. There is no `static: true` or `live: true` flag. It is how the renderer is written.
+
+## Custom artifacts
+
+To render an artifact, like an interactive app or a domain-specific view, you register a renderer for it through the `artifactRenderers` prop.
+
+```tsx
+
+```
+
+See [Custom artifacts](/docs/agent/guides/custom-artifacts) for `defineArtifactRenderer`, the preview and actual views, and persisting edits.
diff --git a/docs/content/docs/agent/core-concepts/conversations.mdx b/docs/content/docs/agent/core-concepts/conversations.mdx
new file mode 100644
index 000000000..56c224dbc
--- /dev/null
+++ b/docs/content/docs/agent/core-concepts/conversations.mdx
@@ -0,0 +1,38 @@
+---
+title: Conversations
+description: How to persist conversations and make them accessible to Agent Interface.
+---
+
+Every conversation with `AgentInterface` is stored through a `storage` adapter. Each message is persisted (its content, any extra context, and the complete response including text and tool calls), and `AgentInterface` reloads the threads and messages of the selected conversation automatically.
+
+On OpenUI Cloud, `useOpenuiCloudStorage` is that adapter. Cloud stores and reloads every conversation, so you run no database and write no persistence logic.
+
+This persistence lets users return to previous conversations, and the full history informs the agent's future responses.
+
+## What gets stored
+
+- **Threads:** the containers for a conversation. Each has an `id`, a title, and metadata like `createdAt`.
+- **Messages:** the turns inside a thread. Each has a `role` (`user`, `assistant`, or `tool`) and its content, including any [generative UI](/docs/agent/core-concepts/generative-ui) or [artifacts](/docs/agent/core-concepts/artifacts) the assistant produced.
+
+
+
+## Accessing conversations
+
+To build your own thread switcher or history view, read the data with hooks instead of touching storage. `useThread` selects from the current thread; `useThreadList` selects from all threads.
+
+```tsx
+import { useThread, useThreadList } from "@openuidev/react-ui";
+
+function ChatCount() {
+ const count = useThreadList((s) => s.threads.length);
+ return {count} chats ;
+}
+```
+
+Both are selector hooks: pass a function that picks the slice you need, and the component re-renders only when that slice changes. They work anywhere inside `AgentInterface`. See [Hooks](/docs/agent/reference/hooks) for the full state.
+
+**Self-hosted:** the scaffold stores conversations in a local store it generates. To use your own database, implement the storage contract. See [Adapters & formats](/docs/agent/reference/adapters-and-formats).
diff --git a/docs/content/docs/agent/core-concepts/generative-ui.mdx b/docs/content/docs/agent/core-concepts/generative-ui.mdx
new file mode 100644
index 000000000..e3bb03293
--- /dev/null
+++ b/docs/content/docs/agent/core-concepts/generative-ui.mdx
@@ -0,0 +1,48 @@
+---
+title: Generative UI
+description: "Render interactive components inline in chat messages: tables, forms, charts, and cards, not just markdown."
+---
+
+An assistant message is normally markdown text. **Generative UI (GenUI)** lets the agent render real components instead: tables, forms, charts, and cards, inline in the message and streaming as the reply arrives.
+
+Turn it on with one prop. On OpenUI Cloud, the components and model instructions are managed for you.
+
+```tsx
+import { AgentInterface } from "@openuidev/react-ui";
+import { openuiLibrary } from "@openuidev/react-ui/genui-lib";
+
+ ;
+```
+
+`openuiLibrary` covers layout, content, tables, charts, forms, and buttons, enough for most agents. To define your own components, see the [OpenUI Lang overview](/docs/openui-lang/overview).
+
+
+
+## Interactivity
+
+Generated components are interactive, and the runtime wires up the behavior. When a user fills a form or clicks a button, one of two things happens:
+
+- **It continues the conversation.** The interaction goes back to the agent as the next turn, carrying the current field values.
+- **It updates in place.** The component refreshes data, changes a value, or opens a link, with no round trip to the model.
+
+Form state is tracked automatically and saved with the thread, so a half-filled form survives a reload. See [Interactivity](/docs/openui-lang/interactivity) for the details.
+
+
+
+## GenUI vs artifacts
+
+Use GenUI when the UI is the reply: a form to fill, a table to scan. Use [artifacts](/docs/agent/core-concepts/artifacts) when the output is a durable thing the user returns to on its own surface. An agent can do both in one thread.
diff --git a/docs/content/docs/agent/core-concepts/tools.mdx b/docs/content/docs/agent/core-concepts/tools.mdx
new file mode 100644
index 000000000..0b6bd4b8f
--- /dev/null
+++ b/docs/content/docs/agent/core-concepts/tools.mdx
@@ -0,0 +1,67 @@
+---
+title: Tools
+description: Let the agent call functions to fetch data and take actions, with your own tools or OpenUI Cloud's built-ins.
+---
+
+A chat model can only produce text. A **tool** is a function you give the agent so it can do more: fetch the weather, query your database, file a ticket. The result folds back into the conversation.
+
+The loop:
+
+1. The model proposes a tool: a **name** plus **arguments**.
+2. **Your code runs it.** It calls an API, reads a database, or does whatever the tool does.
+3. The **result returns to the conversation**, and the agent continues, usually turning it into a final answer.
+
+```
+user: "What's the weather in Tokyo?"
+ └─ model proposes: get_weather({ city: "Tokyo" })
+ └─ your code runs it → { tempC: 22, sky: "clear" }
+ └─ result returns to the agent
+ └─ agent: "It's 22°C and clear in Tokyo right now."
+```
+
+The trust boundary stays in your code. The model only proposes which tool to call and with what arguments. Execution, provider keys, and the tool implementations live on the server and never reach the browser.
+
+
+
+## Integrate your own tool
+
+A tool is a **name**, a **description**, and a **JSON Schema** for its arguments. The model uses the description and schema to decide when and how to call it, so write them like prompts.
+
+```ts
+const tools = [
+ {
+ type: "function",
+ name: "get_weather",
+ description: "Get the current weather for a city.",
+ parameters: {
+ type: "object",
+ properties: {
+ city: { type: "string", description: "City name, e.g. 'Tokyo'" },
+ },
+ required: ["city"],
+ additionalProperties: false,
+ },
+ },
+];
+```
+
+That declaration is what the model sees. The implementation, the code that actually fetches the weather, stays private and runs only when the agent calls the tool.
+
+This declaration is the same on OpenUI Cloud and self-hosted. Your code always runs your own tools. Only the built-in tools below run inside Cloud.
+
+**Self-hosted:** you run the loop in your own route. Call the provider, execute any tool it asks for, append the result, and call again until the model returns text with no more tool calls. See [Self-hosting](/docs/agent/reference/self-hosting).
+
+## Built-in tools (OpenUI Cloud)
+
+OpenUI Cloud ships tools you enable without writing any implementation. Cloud holds the keys and runs them. For example, `image_search` lets the agent pull in relevant images.
+
+## Tools and artifacts
+
+A tool call is also one way an [artifact](/docs/agent/core-concepts/artifacts) gets produced. When the agent calls something like `generate_report`, the interface matches a renderer to that tool by name and draws the result in its own panel. The tool call is the mechanism; the artifact is the output. Many tool calls, like `get_weather`, produce no artifact at all. See [Custom artifacts](/docs/agent/guides/custom-artifacts) to render a tool's result as an artifact.
diff --git a/docs/content/docs/agent/customize/message-rendering.mdx b/docs/content/docs/agent/customize/message-rendering.mdx
new file mode 100644
index 000000000..e1cd8bd51
--- /dev/null
+++ b/docs/content/docs/agent/customize/message-rendering.mdx
@@ -0,0 +1,78 @@
+---
+title: Message rendering
+description: Replace how user and assistant messages render with your own React components, the escape hatch beyond Generative UI.
+---
+
+By default, `AgentInterface` renders each message for you: markdown for assistant text, the user's text as sent, tool calls inline. [Generative UI](/docs/agent/core-concepts/generative-ui) goes further, letting the model emit rich components inside an assistant message. In both, `AgentInterface` owns the rendering.
+
+The `components` prop is the escape hatch past both. Hand `AgentInterface` your own `AssistantMessage` and `UserMessage` and they render every assistant and user message: markdown, layout, avatars, all of it.
+
+```tsx
+import { AgentInterface } from "@openuidev/react-ui";
+
+ ;
+```
+
+Both keys are optional and independent. Override just one and the other keeps its default rendering.
+
+## What a component receives
+
+A message component renders once per message it handles and receives that message as a `message` prop. An `AssistantMessage` also gets an `isStreaming` boolean, `true` while it is the in-flight reply. Assistant `content` is empty until the first token arrives, so guard it.
+
+```tsx
+import type { AssistantMessage } from "@openuidev/react-ui";
+
+function MyAssistantMessage({
+ message,
+ isStreaming,
+}: {
+ message: AssistantMessage;
+ isStreaming: boolean;
+}) {
+ return {message.content ?? ""}
;
+}
+```
+
+The component re-renders as the reply streams in, with `content` growing token by token. A `UserMessage` works the same way without `isStreaming`. Its `content` may be a string or, for multimodal input, an array.
+
+## A custom AssistantMessage
+
+A complete renderer that adds a copy button while still rendering markdown:
+
+```tsx
+import { AgentInterface, type AssistantMessage } from "@openuidev/react-ui";
+import ReactMarkdown from "react-markdown";
+
+function CustomAssistantMessage({ message }: { message: AssistantMessage }) {
+ const text = message.content ?? "";
+ return (
+
+ {text}
+ navigator.clipboard.writeText(text)}>Copy
+
+ );
+}
+
+ ;
+```
+
+The markdown library is your choice; nothing is imposed once you have taken over.
+
+
+
+## Precedence
+
+When a message renders, `AgentInterface` picks the first matching renderer:
+
+1. **`components`:** your explicit override, when set for that message.
+2. **`componentLibrary` (GenUI):** rich rendering so the model can emit components inline.
+3. **Built-in default:** markdown for assistant, plain text for user, tool calls inline.
+
+The props compose. Set `components.AssistantMessage` and your component wins for assistant messages, while user messages still flow through GenUI or the default.
\ No newline at end of file
diff --git a/docs/content/docs/agent/customize/sidebar.mdx b/docs/content/docs/agent/customize/sidebar.mdx
new file mode 100644
index 000000000..8943e77b8
--- /dev/null
+++ b/docs/content/docs/agent/customize/sidebar.mdx
@@ -0,0 +1,98 @@
+---
+title: Sidebar
+description: Add your own nav items to the left sidebar, or replace it entirely through the Sidebar slot.
+---
+
+The left sidebar holds the header, the thread list, and (when artifact storage is configured) an artifact entry. Add a couple of your own links above the threads, or take over the whole region.
+
+Both go through the `Sidebar` slot. Its children replace the sidebar's inner content, so you re-include the default pieces you want to keep. `SidebarContent` and `SidebarSeparator` are primitives you compose inside the slot, not slots themselves.
+
+## Add nav items above the thread list
+
+The common case: a few of your own links, the default thread list underneath. Pass children to the `Sidebar` slot and re-include `SidebarHeader`, `ArtifactNav`, and `ThreadList`.
+
+```tsx
+import { AgentInterface } from "@openuidev/react-ui";
+import { Home, LayoutDashboard } from "lucide-react";
+
+
+
+
+
+ } path="home">
+ Home
+
+ } path="dashboard">
+ Dashboard
+
+
+
+
+ {/* keep the defaults */}
+
+
+
+
+
+```
+
+A `SidebarItem` with `path` navigates. Without one, handle the click yourself via `onClick`. When `path` is set, the item highlights while its route is active, and clicking it calls the router. Your `onClick` runs first; call `event.preventDefault()` to suppress navigation.
+
+
+
+## Wire items to pages
+
+A navigating item only changes the route. Pair it with a `Route` to render something there.
+
+```tsx
+Dashboard
+
+
+
+
+```
+
+When the path matches, the route replaces the thread region; clicking a thread returns to the conversation.
+
+## Replace the whole sidebar
+
+For full control over header placement, section order, and dividers, compose every piece yourself. Nothing default is rendered; you own the structure.
+
+```tsx
+import { AgentInterface } from "@openuidev/react-ui";
+import { Home, LifeBuoy } from "lucide-react";
+
+
+
+
+
+
+
+
+ } path="home">
+ Home
+
+
+
+
+
+
+
+ }
+ onClick={() => window.open("https://docs.example.com", "_blank")}
+ >
+ Help & docs
+
+
+
+
+```
+
+`ArtifactNav` renders nothing unless `storage.artifact` is configured. It auto-appears in the default sidebar and stays safe to include in a custom one regardless. With [artifact categories](/docs/agent/core-concepts/artifacts) it renders one entry per category, otherwise a single "Artifacts" item.
+
+Put `SidebarHeader` inside the slot, not at the top level. When you replace the sidebar, you compose the header there too. `NewChatButton` is not part of the default sidebar content (it lives in the mobile header), so place it yourself if you want it on desktop.
diff --git a/docs/content/docs/agent/customize/welcome-and-starters.mdx b/docs/content/docs/agent/customize/welcome-and-starters.mdx
new file mode 100644
index 000000000..2ace35ef0
--- /dev/null
+++ b/docs/content/docs/agent/customize/welcome-and-starters.mdx
@@ -0,0 +1,87 @@
+---
+title: Welcome & starters
+description: Customize the empty-state welcome screen and the conversation starters that prime a new thread.
+---
+
+When a thread has no messages, `AgentInterface` shows an empty state: a **Welcome** screen with **conversation starters**, clickable prompts that kick off the conversation. Both are configurable, from a one-line greeting to a fully custom panel. The empty state disappears once the first message lands.
+
+## Conversation starters
+
+A starter is a clickable chip with display text and the prompt it sends. Pass an array via the `starters` prop:
+
+```tsx
+import { AgentInterface, fetchLLM, agUIAdapter } from "@openuidev/react-ui";
+
+const llm = fetchLLM({ url: "/api/chat", streamAdapter: agUIAdapter() });
+
+const starters = [
+ {
+ displayText: "Summarize my latest report",
+ prompt: "Summarize the key findings from my most recent quarterly report.",
+ },
+ {
+ displayText: "Build a revenue dashboard",
+ prompt: "Build a dashboard with a bar chart of monthly revenue and a total summary card.",
+ },
+];
+
+export default function App() {
+ return ;
+}
+```
+
+Each entry is `{ displayText, prompt, icon? }`. Clicking a starter sends its `prompt` as a user message, exactly as if the user typed it, so write `prompt` as a full instruction. Omit `icon` for a default lightbulb, pass a React node for a custom glyph, or pass `<>>` for none. `starterVariant` is `"short"` (compact pills) or `"long"` (a vertical list with icons).
+
+`starters` and `starterVariant` set on `AgentInterface` flow down to the Welcome and Composer slots. Each slot can override with its own value. Pass `[]` to a slot to suppress inherited starters there.
+
+## The Welcome slot
+
+`AgentInterface.Welcome` is the greeting area above the composer. Pass props to keep the default layout with your own content:
+
+```tsx
+
+
+
+```
+
+The `image` prop takes `{ url: string }` or any React node. `starters` set here overrides the inherited value.
+
+### Replace it entirely
+
+Pass children to take over the whole welcome area. `title`, `description`, `image`, `starters`, and `starterVariant` are then ignored. A custom Welcome does not render starters for you, so to send a message from your own button, call `processMessage` from `useThread`:
+
+```tsx
+import { AgentInterface, useThread } from "@openuidev/react-ui";
+
+function Starter({ label, prompt }: { label: string; prompt: string }) {
+ const processMessage = useThread((s) => s.processMessage);
+ return (
+ processMessage({ role: "user", content: prompt })}>
+ {label}
+
+ );
+}
+
+
+
+
+
What can I help you build?
+
+
+
+ ;
+```
+
+`processMessage({ role: "user", content })` sends a user message and starts the run, the same as typing in the composer.
+
+
diff --git a/docs/content/docs/agent/getting-started/introduction.mdx b/docs/content/docs/agent/getting-started/introduction.mdx
new file mode 100644
index 000000000..a38a4ee66
--- /dev/null
+++ b/docs/content/docs/agent/getting-started/introduction.mdx
@@ -0,0 +1,48 @@
+---
+title: Agent Interface
+description: "A generative-UI toolkit for building agent interfaces in React: interactive components and artifacts inside a complete, streaming chat, on OpenUI Cloud or your own backend."
+---
+
+Agent Interface helps developers ship production agent UIs. Your agent renders interactive components and artifacts (dashboards, slides, reports) inside a complete, streaming chat, on OpenUI Cloud or your own backend.
+
+
+
+## Why Agent Interface?
+
+Building an agent UI from scratch is a lot of work before you ship a single feature. You wire up streaming and thread history, render every message, build the interactive components your agent returns, add panels for richer outputs, theme it all, and make it work on mobile.
+
+Agent Interface gives you that in one component. Give it a component library and your agent renders those components inline, streaming their props as they arrive. Durable outputs like dashboards, slides, and reports become artifacts your users can open, revisit, and edit. Connect it to OpenUI Cloud and it handles conversations, generative UI, and artifacts for you. You can also run it on your own backend.
+
+```tsx
+import { AgentInterface, fetchLLM, openAIResponsesAdapter, openAIConversationMessageFormat } from "@openuidev/react-ui";
+import { openuiLibrary } from "@openuidev/react-ui/genui-lib";
+
+// Point the llm adapter at your backend route.
+const llm = fetchLLM({
+ url: "/api/chat",
+ streamAdapter: openAIResponsesAdapter(),
+ messageFormat: openAIConversationMessageFormat,
+});
+
+export default function App() {
+ return ;
+}
+```
+
+That is the whole integration. `AgentInterface` renders the streaming chat with generative UI on. Point `storage` at OpenUI Cloud (or your own backend) to add conversation history and artifacts.
+
+## What's included
+
+- **``:** a complete chat UI out of the box, with a sidebar, thread list, composer, streaming, and a responsive layout.
+- **Generative UI:** your agent renders rich, interactive components inline in the conversation instead of plain text.
+- **Artifacts:** durable outputs like dashboards, slides, and reports, opened in side panels and full pages.
+- **Fully customizable:** override any slot, theme with design tokens, and swap in your own message rendering.
+- **OpenUI Cloud or self-hosted:** managed conversations, generative UI, and artifacts, or run it on your own backend.
+
diff --git a/docs/content/docs/agent/getting-started/openui-cloud.mdx b/docs/content/docs/agent/getting-started/openui-cloud.mdx
new file mode 100644
index 000000000..f57c865ec
--- /dev/null
+++ b/docs/content/docs/agent/getting-started/openui-cloud.mdx
@@ -0,0 +1,60 @@
+---
+title: OpenUI Cloud
+description: "The managed backend for Agent Interface: conversation history, production-grade generative UI, and prebuilt presentation and report artifacts."
+---
+
+OpenUI Cloud is the managed backend for Agent Interface. It is built on the open-source OpenUI rendering engine and adds the production layers on top, so you point your app at it and ship instead of operating that infrastructure yourself.
+
+
+
+## What you get
+
+- **Conversation history.** Threads and messages are stored and reloaded for you. No database to run, no endpoints to write.
+- **Production-grade generative UI.** A pre-tested, responsive, accessible component set. Invalid model output is detected and corrected before the user sees it, and a middleware layer normalizes model quirks so generation stays consistent across providers and model versions.
+- **Prebuilt artifacts.** Reports and presentations the agent generates and renders out of the box, with no renderer to build.
+- **Theming and white-labeling.** Fonts, colors, spacing, and component styles are configurable, so every agent-rendered UI is on-brand by default. Multiple brand configurations are supported.
+- **Production hardening.** Model fallbacks and a degraded mode when a provider is slow or down, version pinning and rollback to a known-good setup, and observability with an audit trail: render success, latency, token usage, and a record of what was rendered for whom.
+
+## Connect
+
+`AgentInterface` connects to Cloud through two props, both wired by the scaffold (or by hand, following the `openui-cloud` example in the repo):
+
+- **`llm`** points at a thin `/api/chat` route in your app that proxies to Cloud's Responses endpoint. Your `THESYS_API_KEY` stays on that route and never reaches the browser. On the client it uses `openAIResponsesAdapter()` with `openAIConversationMessageFormat`.
+- **`storage`** is `useOpenuiCloudStorage()` from `@openuidev/thesys`. It reads conversations and artifacts from Cloud directly, authenticated by a short-lived frontend token your app mints from a `/api/frontend-token` route (so the server key never reaches the browser).
+
+The component set (`chatLibrary`), artifact renderers, and categories also come from `@openuidev/thesys`.
+
+Add your server-side key:
+
+```bash
+THESYS_API_KEY=sk-th-your-key
+```
+
+Generate a key in the [Thesys console](https://console.thesys.dev/keys). See [Quickstart](/docs/agent/getting-started/quickstart) to scaffold a Cloud app.
+
+## OpenUI vs OpenUI Cloud
+
+| | OpenUI (open source) | OpenUI Cloud |
+|---|---|---|
+| Generative UI rendering | ✓ | ✓ |
+| Streaming and progressive rendering | ✓ | ✓ |
+| Production-grade components | Basic | Optimized, cross-browser tested |
+| Prebuilt report and presentation artifacts | ✗ | ✓ |
+| Theming and white-labeling | ✗ | ✓ |
+| Error detection and correction | ✗ | ✓ |
+| Cross-model consistency | ✗ | ✓ |
+| Fallbacks, versioning, observability | ✗ | ✓ |
+
+## Roadmap
+
+Coming next:
+
+- **Document exports.** Download reports and presentations as PDF, DOCX, and PPT.
+- **Live dashboards.** Dashboards backed by your business-specific data.
+- **Manual editing.** Edit generated reports and presentations by hand.
+- **Insights.** See what users ask, where they hit dead ends, and which usage patterns to build for next.
+- **Continual learning and memory.** Cloud learns how individual users prefer information presented and adapts over time.
diff --git a/docs/content/docs/agent/getting-started/quickstart.mdx b/docs/content/docs/agent/getting-started/quickstart.mdx
new file mode 100644
index 000000000..8ef1f7186
--- /dev/null
+++ b/docs/content/docs/agent/getting-started/quickstart.mdx
@@ -0,0 +1,84 @@
+---
+title: Quickstart
+description: Scaffold a working streaming chat agent (conversation history, tools, artifacts, and generative UI) in a few minutes with the OpenUI CLI.
+---
+
+The CLI scaffolds a complete Next.js app: a streaming chat with a sidebar, thread list, composer, and generative UI on by default. You write no boilerplate: create, connect, run.
+
+## 1. Create
+
+Run the create command and answer the prompts. One prompt asks **OpenUI Cloud or self-hosted?** Your choice decides which backend the scaffold wires up.
+
+
+ ```bash npx @openuidev/cli@latest create ```
+ ```bash pnpm dlx @openuidev/cli@latest create ```
+ ```bash yarn dlx @openuidev/cli@latest create ```
+ ```bash bunx @openuidev/cli@latest create ```
+
+
+When it finishes, move into the project:
+
+```bash
+cd my-agent
+```
+
+## 2. Connect
+
+
+
+
+Connect the scaffold to OpenUI Cloud. Generate an API key in the [Thesys console](https://console.thesys.dev/keys) and add it to `.env.local`:
+
+```bash
+THESYS_API_KEY=sk-th-your-key-here
+```
+
+The scaffold's `/api/chat` route proxies to Cloud with this key, so it stays on the server. Cloud manages conversation history, artifacts, and built-in tools, so there's no database or renderers to run. See [OpenUI Cloud](/docs/agent/getting-started/openui-cloud).
+
+
+
+
+The scaffold generates an `/api/chat` route that calls OpenAI on the server. Add your key to `.env.local`:
+
+```bash
+OPENAI_API_KEY=sk-your-key-here
+```
+
+The key stays on the server. The browser only ever talks to your route, never the provider.
+
+
+
+
+## 3. Run
+
+Start the dev server:
+
+
+ ```bash npm run dev ```
+ ```bash pnpm dev ```
+ ```bash yarn dev ```
+ ```bash bun dev ```
+
+
+Open [http://localhost:3000](http://localhost:3000) and send a message. The response streams in token by token, and the assistant can render rich, interactive components inline.
+
+
+
+## What you get
+
+A working agent out of the box:
+
+- **Streaming chat:** responses render token by token in a full layout (sidebar, thread list, composer).
+- **Conversation history that persists:** past threads are saved and reloadable, not reset on refresh. See [Conversations](/docs/agent/core-concepts/conversations).
+- **Tool calling:** the agent can call tools mid-response, including built-in Cloud tools like `image_search` (no code to enable). See [Tools](/docs/agent/core-concepts/tools).
+- **An example artifact:** the scaffold ships a slides/report artifact you can prompt for and open in the workspace. See [Artifacts](/docs/agent/core-concepts/artifacts).
+- **Generative UI, on by default:** the assistant renders interactive components inside messages. See [Generative UI](/docs/agent/core-concepts/generative-ui).
+
+The difference is only in the backing: **OpenUI Cloud** manages history, artifacts, and built-in tools for you, while **self-hosted** uses your own OpenAI route and a local store the scaffold generates.
diff --git a/docs/content/docs/agent/guides/custom-artifacts.mdx b/docs/content/docs/agent/guides/custom-artifacts.mdx
new file mode 100644
index 000000000..0fa7a993b
--- /dev/null
+++ b/docs/content/docs/agent/guides/custom-artifacts.mdx
@@ -0,0 +1,102 @@
+---
+title: Custom artifacts
+description: "Add a custom artifact type beyond Cloud's built-in slides and reports: produce the data, write a renderer, register it."
+---
+
+OpenUI Cloud ships built-in artifact types (slides, reports) already wired to render. When your agent produces something they don't cover, like a code snippet or a custom app, you add your own type: a tool returns the data, a renderer turns it into UI, and the `artifactRenderers` prop registers it. The same renderer draws the artifact whether it just streamed in or was loaded from storage.
+
+This guide builds one custom artifact end to end: a code snippet the agent generates. For the concept, see [Artifacts](/docs/agent/core-concepts/artifacts). For every renderer field and edge case, see [defineArtifactRenderer](/docs/agent/reference/define-artifact-renderer).
+
+## 1. Produce the artifact
+
+An artifact reaches the UI as the result of a [tool call](/docs/agent/core-concepts/tools). Your agent calls a `create_code_artifact` tool, and its arguments are the artifact's data. The renderer below matches that tool by name and draws the result, the same way on OpenUI Cloud or your own backend.
+
+Keep that shape identical to whatever you persist, so one renderer covers both the freshly-streamed and the loaded-from-storage cases. For this example the data is:
+
+```ts
+interface CodeArtifact {
+ language: string;
+ title: string;
+ code: string;
+}
+```
+
+## 2. Write the renderer
+
+A renderer is keyed to an artifact `type`, not to a tool. A tool call is just one way the data arrives. You describe it with `defineArtifactRenderer`: a `parser` that reads the raw envelope into typed props, a `preview` (the inline card in the chat), and an `actual` (the full view in a side panel or page).
+
+The rule that matters most: the `parser` must never throw. It runs on every stream update, including before the result exists, so read `response` as the source of truth, fall back to a tolerant parse of `args` for an early preview, and bail with `null` until you have enough to draw.
+
+```tsx
+import { defineArtifactRenderer, CodeBlock } from "@openuidev/react-ui";
+
+const codeArtifactRenderer = defineArtifactRenderer({
+ type: "code_artifact",
+ toolName: "create_code_artifact",
+
+ parser: ({ args, response }, { isStreaming }) => {
+ // Storage path: args is undefined, response is the persisted content.
+ // Tool-call path: response is null until the result lands; args streams
+ // in first as a partial JSON string.
+ const data = (response as CodeArtifact | null) ?? tryParse(args);
+ if (!data?.title) return null; // not enough to draw yet
+
+ return {
+ props: data,
+ // Don't register in the workspace until the result is final.
+ meta: isStreaming
+ ? null
+ : { id: `code:${data.title}`, version: 1, heading: data.title },
+ };
+ },
+
+ preview: (props, controls) => (
+
+ {props.title}
+
+ {props.language}
+ {controls.isStreaming ? " · building…" : ""}
+
+
+ ),
+
+ actual: (props) => (
+
+
+
+ ),
+});
+
+// Best-effort parse of a possibly-partial JSON arg string.
+function tryParse(args: unknown): CodeArtifact | null {
+ if (typeof args !== "string") return null;
+ try {
+ return JSON.parse(args) as CodeArtifact;
+ } catch {
+ return null; // still mid-stream
+ }
+}
+```
+
+A few things to notice:
+
+- **`preview` vs `actual`:** `preview` is the compact card inline in the message; keep it small and give it an affordance that calls `controls.open()`. `actual` is the full view in the detailed-view panel or the artifact page. Here it uses the built-in `CodeBlock` component.
+- **`meta`:** return `meta: null` while streaming so the artifact joins the workspace only once complete; return `{ id, version, heading }` on the final pass to register it. Keep `id` stable across re-runs of the same artifact.
+- **`controls.isStreaming`:** the same component instance is reused across the streaming-to-complete transition, so this is a state swap, not a remount.
+
+## 3. Register it
+
+Hand your renderer to `` through the `artifactRenderers` prop. Pass as many as you like:
+
+```tsx
+import { AgentInterface } from "@openuidev/react-ui";
+
+ ;
+```
+
+The interface indexes each renderer by `toolName` (the tool-call path) and by `type` (the storage path), so a `create_code_artifact` call renders as an inline card that opens a full panel, and any `code_artifact` loaded from storage renders through the same code. On a duplicate `toolName` or `type`, the first registration wins, so put your custom renderers before any built-in defaults.
diff --git a/docs/content/docs/agent/guides/migrating.mdx b/docs/content/docs/agent/guides/migrating.mdx
new file mode 100644
index 000000000..4c8f7f59e
--- /dev/null
+++ b/docs/content/docs/agent/guides/migrating.mdx
@@ -0,0 +1,225 @@
+---
+title: Migrating from flat-props ChatProvider
+description: Move from the legacy flat-props chat component to AgentInterface and its two adapter objects — with a complete old-to-new mapping and the behavior changes to watch for.
+---
+
+The legacy chat component took its backend wiring as a flat bag of props — `apiUrl`, `streamProtocol`, `processMessage`, `threadApiUrl`, `fetchThreadList`, `loadThread`, and a dozen more — directly on the component. `AgentInterface` replaces all of that with **two adapter objects**: an `llm` for producing replies and a `storage` for persistence. The component's prop surface shrinks to something small and stable, and each adapter is testable in isolation — you can unit-test a `ChatLLM` or `ChatStorage` without rendering anything.
+
+The whole migration, in one idea: the flat backend props are **removed** (not deprecated), and their values move into `fetchLLM({ ... })` for the LLM and `restStorage({ ... })` for threads.
+
+Everything imports from `@openuidev/react-ui`: `AgentInterface`, the `fetchLLM` / `restStorage` / `defineArtifactRenderer` factories, the stream adapters, the message formats, and hooks like `useArtifactList`.
+
+## The whole diff
+
+If you were on the common setup — POST to a route, REST-backed threads — the migration is: build two adapters, pass two props.
+
+**Before:**
+
+```tsx
+// legacy flat-props chat component
+ ;
+```
+
+**After:**
+
+```tsx
+import { AgentInterface, fetchLLM, restStorage, openAIAdapter, openAIMessageFormat } from "@openuidev/react-ui";
+
+const llm = fetchLLM({
+ url: "/api/chat",
+ streamAdapter: openAIAdapter(),
+ messageFormat: openAIMessageFormat,
+});
+
+const storage = restStorage({
+ baseUrl: "/api/threads",
+ messageFormat: openAIMessageFormat,
+});
+
+ ;
+```
+
+Your route handler doesn't change: `fetchLLM` POSTs the same `{ threadId, messages }` to `url`, and `restStorage` calls the same thread endpoints `threadApiUrl` did. Everything else on this page is the field-by-field mapping behind those two factory calls.
+
+## Old → new at a glance
+
+| Legacy flat prop | New home | Notes |
+|------------------|----------|-------|
+| `apiUrl` | `fetchLLM({ url })` | The LLM endpoint moves into the factory's `url`. |
+| `streamProtocol` | `fetchLLM({ streamAdapter })` | String enum → a stream-adapter **factory call** (`openAIAdapter()`). |
+| `messageFormat` | `fetchLLM({ messageFormat })` *(and `restStorage({ messageFormat })`)* | String enum → a `MessageFormat` value (`openAIMessageFormat`). |
+| `processMessage` | `ChatLLM.send` | Custom send logic becomes a `send` implementation. `abortController` → the `signal` you receive. |
+| `threadApiUrl` | `restStorage({ baseUrl })` | The thread REST root moves into `restStorage`. |
+| `fetchThreadList` | `storage.thread.listThreads` | Now takes an optional cursor; returns `{ threads, nextCursor? }`. |
+| `createThread` | `storage.thread.createThread` | Receives the first `UserMessage`, returns the new `Thread`. |
+| `updateThread` | `storage.thread.updateThread` | Takes a full `Thread`, returns the updated `Thread`. |
+| `deleteThread` | `storage.thread.deleteThread` | Takes an id. |
+| `loadThread` | `storage.thread.getMessages` | Renamed; takes a `threadId`, returns `Message[]`. |
+| `appRenderers` | `artifactRenderers` | Prop rename; array of renderer configs. |
+| `defineAppRenderer` | `defineArtifactRenderer` | `kind` → `type`; `toolName` is `string \| string[]`; parser returns `{ props, meta }`. |
+| `useAppList` | `useArtifactList` | Hook rename; per-thread artifact registry. |
+| `Artifact*` panel APIs | `DetailedView*` | The in-thread panel hooks were renamed around "detailed view." |
+| legacy chat component | `AgentInterface` | Component rename, same package. |
+
+## LLM props → `llm`
+
+Four old props collapse into one `fetchLLM` call. Two things change beyond the renames:
+
+- **`streamProtocol` was a string; `streamAdapter` is a factory call.** Pick the adapter matching your provider's wire format and **call it** — `openAIAdapter()`, not the bare reference. The options are `agUIAdapter`, `openAIAdapter`, `openAIReadableStreamAdapter`, `openAIResponsesAdapter`, and `langGraphAdapter`.
+- **`messageFormat` was a string; it's now a value.** Use `openAIMessageFormat`, `openAIConversationMessageFormat` (the one that pairs with `openAIResponsesAdapter()`), `langGraphMessageFormat`, or `identityMessageFormat` (the default — the canonical `Message` array, unchanged).
+
+### `processMessage` → a custom `ChatLLM`
+
+If `processMessage` did something `fetchLLM` can't express — a non-`fetch` transport, a websocket, bespoke request shaping — implement the `ChatLLM` interface directly instead of calling `fetchLLM`.
+
+**Before** — `processMessage` received an `abortController`:
+
+```tsx
+ {
+ return fetch("/api/chat", {
+ method: "POST",
+ body: JSON.stringify({ messages }),
+ signal: abortController.signal,
+ });
+ }}
+/>;
+```
+
+**After** — `send` receives the `signal` directly, and the parser moves onto the object as `streamProtocol`:
+
+```tsx
+import { AgentInterface, type ChatLLM, openAIAdapter } from "@openuidev/react-ui";
+
+const llm: ChatLLM = {
+ streamProtocol: openAIAdapter(),
+ async send({ threadId, messages, signal }) {
+ return fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ threadId, messages }),
+ signal, // was abortController.signal
+ });
+ },
+};
+
+ ;
+```
+
+The behavior change to internalize: **you no longer create or own an `AbortController`.** `AgentInterface` owns it and hands you the `signal` — wired to the UI's stop control — in every `send` call. Just forward it.
+
+## Thread props → `storage`
+
+`threadApiUrl` plus the per-operation callbacks become a single `ChatStorage` whose `thread` member holds five methods.
+
+### The REST case → `restStorage`
+
+If your old setup pointed `threadApiUrl` at a REST backend, the migration is one factory call:
+
+```tsx
+import { restStorage } from "@openuidev/react-ui";
+
+const storage = restStorage({ baseUrl: "/api/threads" }); // was threadApiUrl
+
+ ;
+```
+
+`restStorage` hits the exact endpoints the old `threadApiUrl` prop did — `GET {baseUrl}/get`, `POST {baseUrl}/create`, `GET {baseUrl}/get/{threadId}`, `PATCH {baseUrl}/update/{id}`, `DELETE {baseUrl}/delete/{id}` — so an existing backend keeps working. Pass `messageFormat` here too if your backend stores messages in a provider shape.
+
+If you pass no `storage` at all, `AgentInterface` uses an internal in-memory store — fine for prototyping, but wiped on reload.
+
+### Custom thread callbacks → `storage.thread.*`
+
+If you supplied individual thread callbacks, each maps to a method on `storage.thread`:
+
+```tsx
+import { AgentInterface, type ChatStorage } from "@openuidev/react-ui";
+
+const storage: ChatStorage = {
+ thread: {
+ listThreads: fetchThreadList, // now takes an optional cursor, returns { threads, nextCursor? }
+ createThread, // receives the first UserMessage, returns the new Thread
+ getMessages: loadThread, // was loadThread — takes threadId, returns Message[]
+ updateThread, // takes a full Thread, returns the updated Thread
+ deleteThread, // takes an id
+ },
+};
+
+ ;
+```
+
+Two renames carry behavior changes: **`fetchThreadList` → `listThreads`** now takes an optional `cursor` and must return `{ threads, nextCursor? }` (cursor pagination for the sidebar's "load more"); **`loadThread` → `getMessages`** is the same job, just renamed for symmetry. If your callbacks already match these shapes, hand them over directly; otherwise wrap them.
+
+## Renderers: `appRenderers` → `artifactRenderers`
+
+The "app renderer" concept was renamed to **artifact renderer** throughout — the prop, the factory, and the parser contract all changed.
+
+```tsx
+import { AgentInterface, defineArtifactRenderer } from "@openuidev/react-ui";
+
+const codeArtifactRenderer = defineArtifactRenderer({
+ type: "code_artifact", // was kind
+ toolName: "create_code_artifact", // now string | string[]
+ parser: ({ args, response }, { isStreaming }) => {
+ const data = response as CodeArtifact | null;
+ if (!data) return null; // tolerate partial data while streaming
+ return {
+ props: data, // props AND meta now come from one return value
+ meta: isStreaming ? null : { id: `code:${data.title}`, version: 1, heading: data.title },
+ };
+ },
+ preview: (props, controls) => ,
+ actual: (props) => ,
+});
+
+ ;
+```
+
+The behavior changes inside the renderer:
+
+- **`kind` → `type`.** A literal string that links the renderer to its stored artifacts' `type`.
+- **`toolName` is now `string | string[]`.** Register one renderer for several tools by passing an array. Names are **literal only** — no RegExp. First registration wins on a duplicate `toolName`.
+- **The parser returns `{ props, meta }` (or `null`), not props alone.** `meta` is `{ id, version, heading } | null`: return the object to render *and* register the artifact in the thread (so it appears in the Workspace rail and artifact lists); return `null` for `meta` to render without registering (the common move while streaming). Returning `null` from the parser entirely skips rendering.
+- **The parser must tolerate partial data.** It's called on every stream update — `response` is `null` until the result lands, and `args` may be a partial JSON string. Guard accordingly.
+
+## Hooks and panel APIs
+
+`useAppList()` → **`useArtifactList(filter?)`**, imported from `@openuidev/react-ui`. It returns the per-thread artifact registry, optionally filtered by `type`:
+
+```tsx
+import { useArtifactList } from "@openuidev/react-ui";
+
+const artifacts = useArtifactList();
+const codeArtifacts = useArtifactList({ type: ["code_artifact"] });
+```
+
+The in-thread panel hooks that used to be named around "Artifact" are now named around **detailed view**: `useActiveDetailedView()`, `useDetailedView(viewId)`, `useDetailedViewStore()`, `useDetailedViewPortalTarget()`. If you reached into the old `Artifact*` panel hooks, swap to these.
+
+"Artifact" now consistently means the durable output (a dashboard, report, app); "detailed view" means the in-thread panel that shows one. The old "app" / "Artifact panel" naming conflated the two.
+
+## Migration checklist
+
+1. Rename the legacy chat component → `AgentInterface` (same `@openuidev/react-ui` package).
+2. Build `llm = fetchLLM({ url, streamAdapter, messageFormat })` from your old `apiUrl` / `streamProtocol` / `messageFormat`. **Call** the adapter (`openAIAdapter()`).
+3. If you had a custom `processMessage`, implement `ChatLLM` directly and forward the `signal` — drop your own `AbortController`.
+4. Build `storage = restStorage({ baseUrl })` from `threadApiUrl`, **or** assemble `storage.thread.*` from your `fetchThreadList` (→ `listThreads`), `createThread`, `updateThread`, `deleteThread`, and `loadThread` (→ `getMessages`).
+5. Pass `llm` (required) and `storage` (optional) to `AgentInterface`.
+6. Rename `appRenderers` → `artifactRenderers` and `defineAppRenderer` → `defineArtifactRenderer`; change `kind` → `type`, allow `toolName` arrays, and return `{ props, meta }` from each parser.
+7. Rename `useAppList` → `useArtifactList` and any `Artifact*` panel hooks → `DetailedView*`.
+8. Delete every remaining flat backend prop — they're gone. If TypeScript flags an unknown prop, find it in the table above and move it into the right adapter.
+
+## Related
+
+
+ The presentation props that stay flat on the component.
+ Reference for `fetchLLM`, `restStorage`, stream adapters, and message formats.
+ The renderer config, parser contract, and `meta` shape.
+ How `llm` and `storage` drive a thread end to end.
+ `useArtifactList`, the `DetailedView*` hooks, and more.
+
diff --git a/docs/content/docs/agent/meta.json b/docs/content/docs/agent/meta.json
new file mode 100644
index 000000000..08d543204
--- /dev/null
+++ b/docs/content/docs/agent/meta.json
@@ -0,0 +1,29 @@
+{
+ "title": "Agent Interface",
+ "root": true,
+ "pages": [
+ "---Getting Started---",
+ "getting-started/introduction",
+ "getting-started/quickstart",
+ "getting-started/openui-cloud",
+ "---Core Concepts---",
+ "core-concepts/generative-ui",
+ "core-concepts/conversations",
+ "core-concepts/tools",
+ "core-concepts/artifacts",
+ "---Customize---",
+ "customize/welcome-and-starters",
+ "customize/sidebar",
+ "customize/message-rendering",
+ "---Reference---",
+ "reference/agentinterface-props",
+ "reference/adapters-and-formats",
+ "reference/define-artifact-renderer",
+ "reference/hooks",
+ "reference/components",
+ "reference/self-hosting",
+ "---Guides---",
+ "guides/custom-artifacts",
+ "guides/migrating"
+ ]
+}
diff --git a/docs/content/docs/agent/reference/adapters-and-formats.mdx b/docs/content/docs/agent/reference/adapters-and-formats.mdx
new file mode 100644
index 000000000..a1e47f4cb
--- /dev/null
+++ b/docs/content/docs/agent/reference/adapters-and-formats.mdx
@@ -0,0 +1,532 @@
+---
+title: Adapters & formats
+description: Complete reference for AgentInterface's backend channels — the ChatLLM/ChatStorage/ThreadStorage/ArtifactStorage interfaces, the fetchLLM and restStorage factories, the Thread type, the bundled stream adapters, and the message formats.
+---
+
+`AgentInterface` talks to your backend through two independent channels, each an *adapter* you supply:
+
+- **`llm`** — a `ChatLLM` that produces replies. **Required.**
+- **`storage`** — a `ChatStorage` that persists threads, messages, and (optionally) artifacts. Optional; defaults to an internal in-memory store that's wiped on reload.
+
+This page is the exhaustive reference for both channels, the factories that build them (`fetchLLM`, `restStorage`), the `Thread` record, the bundled **stream adapters** that parse provider wire formats, and the **message formats** that translate message shapes. For the task-oriented walkthroughs, see [Conversations](/docs/agent/core-concepts/conversations) and [Self-hosting](/docs/agent/reference/self-hosting).
+
+## Import sources
+
+Everything on this page imports from `@openuidev/react-ui`: `AgentInterface`, `useNav`, the `fetchLLM` and `restStorage` factories, the adapter and storage types, the stream adapters, and the message formats.
+
+```ts
+// Factories + types
+import {
+ fetchLLM,
+ restStorage,
+ type ChatLLM,
+ type ChatStorage,
+ type ThreadStorage,
+ type ArtifactStorage,
+ type Thread,
+ type MessageFormat,
+ type StreamProtocolAdapter,
+} from "@openuidev/react-ui";
+
+// Stream adapters + message formats
+import {
+ agUIAdapter,
+ openAIAdapter,
+ openAIReadableStreamAdapter,
+ openAIResponsesAdapter,
+ langGraphAdapter,
+ openAIMessageFormat,
+ openAIConversationMessageFormat,
+ langGraphMessageFormat,
+ identityMessageFormat,
+} from "@openuidev/react-ui";
+```
+
+All five stream adapters are **factory functions** — always call them with `()`. `streamAdapter: openAIAdapter()` is correct; a bare `streamAdapter: openAIAdapter` is wrong.
+
+## The two channels
+
+```tsx
+import { AgentInterface } from "@openuidev/react-ui";
+
+ ;
+```
+
+`llm` and `storage` are wholly separate. You can run a real LLM with ephemeral storage, or persistent storage with a placeholder LLM — they're configured and resolved independently.
+
+---
+
+## `ChatLLM`
+
+The object `AgentInterface` calls when the user sends a message.
+
+```ts
+interface ChatLLM {
+ send(params: {
+ threadId: string;
+ messages: Message[];
+ signal: AbortSignal;
+ }): Promise;
+ streamProtocol: StreamProtocolAdapter;
+}
+```
+
+- **`send`** receives the current `threadId`, the full `messages` array, and an `AbortSignal` wired to the UI's stop/cancel control. It returns a streaming `Response` (a standard `fetch` `Response` whose body is a readable stream).
+- **`streamProtocol`** is the [stream adapter](#stream-adapters) that parses the returned `Response` body into the canonical AG-UI events the UI renders (`TEXT_MESSAGE_*`, `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END`, `TOOL_CALL_RESULT`, `RUN_ERROR`).
+
+Most apps never construct a `ChatLLM` by hand — `fetchLLM` builds one for the common "POST to my route, parse the stream" case. Implement the interface directly only when you need to call `send` in a way `fetchLLM` doesn't cover (e.g. a non-`fetch` transport).
+
+### `fetchLLM`
+
+Builds a `ChatLLM` that POSTs a JSON body to a URL and parses the streamed reply.
+
+```ts
+function fetchLLM(options: {
+ url: string;
+ streamAdapter: StreamProtocolAdapter;
+ messageFormat?: MessageFormat;
+ headers?: Record;
+ fetch?: typeof fetch;
+}): ChatLLM;
+```
+
+| Option | Type | Required | What it does |
+|-----------------|----------------------------|----------|-----------------------------------------------------------------------------------------|
+| `url` | `string` | Yes | The endpoint `fetchLLM` POSTs to (your own route — never the provider directly). |
+| `streamAdapter` | `StreamProtocolAdapter` | Yes | Parses the streamed response into UI events. Becomes the `ChatLLM`'s `streamProtocol`. |
+| `messageFormat` | `MessageFormat` | No | Converts outgoing `messages` to your provider's shape. Defaults to `identityMessageFormat`. |
+| `headers` | `Record` | No | Extra headers merged into every request (e.g. a session token). **Browser-visible.** |
+| `fetch` | `typeof fetch` | No | Override `fetch` (auth wrappers, instrumentation, tests). |
+
+**Request body.** `fetchLLM` POSTs JSON `{ threadId, messages }`, where `messages` is run through `messageFormat.toApi`, and threads the `AbortSignal` from the UI:
+
+```ts
+// Inside fetchLLM — shown for reference; you don't write this.
+fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...headers },
+ body: JSON.stringify({ threadId, messages: messageFormat.toApi(messages) }),
+ signal, // wired to the UI's cancel/stop control
+});
+```
+
+Your route therefore always receives `{ threadId, messages }` and must return a streaming `Response`. `fetchLLM` runs that response through `streamAdapter`.
+
+```tsx
+import { fetchLLM, openAIReadableStreamAdapter, openAIMessageFormat } from "@openuidev/react-ui";
+
+const llm = fetchLLM({
+ url: "/api/chat",
+ streamAdapter: openAIReadableStreamAdapter(),
+ messageFormat: openAIMessageFormat,
+});
+```
+
+`headers` are headers on the browser's request to *your* route. Use them for things like a session token or CSRF token — never a provider API key. The provider key lives only in your server-side route handler.
+
+---
+
+## `ChatStorage`
+
+The persistence channel. A `ChatStorage` is an object with a required `thread` member and an optional `artifact` member:
+
+```ts
+interface ChatStorage {
+ thread: ThreadStorage;
+ artifact?: ArtifactStorage;
+}
+```
+
+- **`thread`** persists conversations — the thread list, message history, renames, and deletes.
+- **`artifact`** persists durable artifacts (dashboards, reports, presentations, apps) and powers the artifact browser. Optional and independent of `thread`. See [`ArtifactStorage`](#artifactstorage-optional) below and [Artifacts](/docs/agent/core-concepts/artifacts).
+
+Omit `storage` entirely and `AgentInterface` uses an internal in-memory store — fine for prototyping, but ephemeral.
+
+### `ThreadStorage`
+
+The five methods that back thread management. Implement these and the default sidebar's thread list, "New chat" button, thread switching, and deletion all operate against your backend.
+
+```ts
+interface ThreadStorage {
+ listThreads(cursor?: string): Promise<{ threads: Thread[]; nextCursor?: string }>;
+ createThread(firstMessage: UserMessage): Promise;
+ getMessages(threadId: string): Promise;
+ updateThread(thread: Thread): Promise;
+ deleteThread(id: string): Promise;
+}
+```
+
+| Method | Returns | When `AgentInterface` calls it |
+|--------|---------|--------------------------------|
+| `listThreads(cursor?)` | `{ threads, nextCursor? }` | Populating the thread list; loading more when `nextCursor` exists and the user scrolls. |
+| `createThread(firstMessage)` | the new `Thread` | The user sends the first message of a new chat. |
+| `getMessages(threadId)` | `Message[]` | The user opens a thread. |
+| `updateThread(thread)` | the updated `Thread` | A thread changes (e.g. a rename). |
+| `deleteThread(id)` | `void` | The user deletes a thread. |
+
+Implement these directly when your storage doesn't fit the REST shape `restStorage` expects — a different route layout, GraphQL, or a client-side store like IndexedDB:
+
+```ts
+import type { ChatStorage } from "@openuidev/react-ui";
+
+export const storage: ChatStorage = {
+ thread: {
+ async listThreads(cursor) {
+ const res = await fetch(`/threads?cursor=${cursor ?? ""}`);
+ return res.json(); // { threads, nextCursor? }
+ },
+ async createThread(firstMessage) {
+ const res = await fetch("/threads", {
+ method: "POST",
+ body: JSON.stringify({ firstMessage }),
+ });
+ return res.json(); // a Thread
+ },
+ async getMessages(threadId) {
+ const res = await fetch(`/threads/${threadId}/messages`);
+ return res.json(); // Message[]
+ },
+ async updateThread(thread) {
+ const res = await fetch(`/threads/${thread.id}`, {
+ method: "PUT",
+ body: JSON.stringify(thread),
+ });
+ return res.json(); // the updated Thread
+ },
+ async deleteThread(id) {
+ await fetch(`/threads/${id}`, { method: "DELETE" });
+ },
+ },
+};
+```
+
+`restStorage` is itself just a `ChatStorage` built this way for the common REST case.
+
+### `Thread`
+
+The thread record at the storage boundary.
+
+```ts
+type Thread = {
+ id: string;
+ title: string;
+ createdAt: string | number;
+ isPending?: boolean;
+};
+```
+
+| Field | Type | Notes |
+|-------|------|-------|
+| `id` | `string` | Stable thread identifier. |
+| `title` | `string` | Shown in the sidebar thread list. |
+| `createdAt` | `string \| number` | ISO string or epoch — used for ordering/display. |
+| `isPending` | `boolean` (optional) | Marks a thread that's being created (e.g. optimistic insert before the create call resolves). |
+
+---
+
+## `restStorage`
+
+Builds a `ChatStorage` wired to a fixed set of REST endpoints under one `baseUrl`. The fastest path to durable history — you implement the endpoints, the factory does the wiring.
+
+```ts
+function restStorage(options: {
+ baseUrl: string;
+ messageFormat?: MessageFormat;
+ headers?: Record;
+ fetch?: typeof fetch;
+}): ChatStorage;
+```
+
+| Option | Type | Required | What it does |
+|-----------------|----------------------------|----------|-------------------------------------------------------------------------------------------|
+| `baseUrl` | `string` | Yes | Root path for the endpoints below (e.g. `/api/threads`). |
+| `messageFormat` | `MessageFormat` | No | Converts messages to/from your stored shape. Defaults to `identityMessageFormat`. |
+| `headers` | `Record` | No | Sent on every request (e.g. a tenant or session header). |
+| `fetch` | `typeof fetch` | No | Override `fetch` (auth, instrumentation, tests). |
+
+```tsx
+import { restStorage, openAIMessageFormat } from "@openuidev/react-ui";
+
+const storage = restStorage({
+ baseUrl: "/api/threads",
+ messageFormat: openAIMessageFormat, // optional
+ headers: { "x-tenant": "acme" }, // optional, sent on every request
+});
+```
+
+### Endpoint contract
+
+Each `ThreadStorage` operation maps to exactly one HTTP call under `baseUrl`. The paths are literal — these are the exact five endpoints you implement.
+
+| Operation | Method | Path | Request body | Response |
+|-----------|--------|------|--------------|----------|
+| List threads | `GET` | `{baseUrl}/get` — or `{baseUrl}/get?cursor={cursor}` when paginating | — | `{ threads: Thread[]; nextCursor?: string }` |
+| Create thread | `POST` | `{baseUrl}/create` | `{ messages: messageFormat.toApi([firstMessage]) }` | the new `Thread` |
+| Get messages | `GET` | `{baseUrl}/get/{threadId}` | — | `Message[]` (run through `messageFormat.fromApi`) |
+| Update thread | `PATCH` | `{baseUrl}/update/{thread.id}` | the `Thread` | the updated `Thread` |
+| Delete thread | `DELETE` | `{baseUrl}/delete/{id}` | — | — |
+
+With `baseUrl: "/api/threads"` the concrete paths are `/api/threads/get`, `/api/threads/create`, `/api/threads/get/{threadId}`, `/api/threads/update/{thread.id}`, and `/api/threads/delete/{id}`.
+
+### Message format application
+
+`messageFormat` applies at exactly two points:
+
+- **Create** — the request body's `messages` is `messageFormat.toApi([firstMessage])`.
+- **Get messages** — the `{baseUrl}/get/{threadId}` response is run through `messageFormat.fromApi` on the way back.
+
+With the default `identityMessageFormat`, messages cross the wire as the canonical `Message` type unchanged. Pass a provider-specific format (e.g. `openAIMessageFormat`) when your backend stores messages in that provider's shape.
+
+### Error behavior
+
+`restStorage` throws a **descriptive error on any non-`ok` response** (HTTP status outside 200–299). A failing endpoint surfaces clearly rather than being silently swallowed, so a misconfigured route or a backend error propagates to your error boundary instead of leaving the UI in a stuck state.
+
+---
+
+## `ArtifactStorage` (optional)
+
+The optional `artifact` member of `ChatStorage`. Configure it to store durable artifacts and enable the artifact browser. Summarized here for completeness — the task-oriented coverage is in [Artifacts](/docs/agent/core-concepts/artifacts).
+
+```ts
+interface ArtifactStorage {
+ list(params?: {
+ name?: string;
+ type?: string[];
+ cursor?: string;
+ limit?: number;
+ }): Promise<{ artifacts: ArtifactSummary[]; nextCursor?: string }>;
+ get(id: string): Promise;
+ update(patch: { id: string; content: unknown }): Promise;
+}
+
+interface ArtifactSummary {
+ id: string;
+ title: string;
+ type: string;
+ threadId: string; // required — every artifact belongs to a thread
+ updatedAt?: string | number;
+}
+
+interface Artifact extends ArtifactSummary {
+ content: unknown;
+}
+```
+
+- **`list`** — `name` and `type` filtering is **server-side**; the factory passes them through so your backend does the filtering. Paginated via `cursor` / `nextCursor`.
+- **`get`** — returns the full `Artifact`, including `content`.
+- **`update`** — persists an edit to an editable artifact's `content`; returns the updated summary.
+
+Attach it alongside `thread`:
+
+```ts
+const storage: ChatStorage = {
+ thread: threadStorage,
+ artifact: artifactStorage, // optional
+};
+```
+
+---
+
+## Stream adapters
+
+A **stream adapter** parses the streamed `Response` your route returns into the AG-UI events the UI renders. There is one per response *wire format*. It's a factory returning a `StreamProtocolAdapter`:
+
+```ts
+interface StreamProtocolAdapter {
+ parse(response: Response): AsyncIterable;
+}
+```
+
+`fetchLLM` calls `parse` on the `Response` your route returns and consumes the yielded AG-UI events. All five bundled adapters are factories you call to get an instance — `agUIAdapter()`, `openAIAdapter()`, `openAIReadableStreamAdapter()`, `openAIResponsesAdapter()`, `langGraphAdapter()`; only `langGraphAdapter()` accepts options.
+
+A malformed line in the stream is logged to the console and skipped — it does not abort the whole stream. Provider-level failures should be surfaced as a `RUN_ERROR` event (the OpenAI Responses and LangGraph adapters do this for their error events).
+
+### Selection guide
+
+Start from your provider and how the route streams its reply. The stream adapter and message format are independent knobs — pick the adapter to match how your backend *streams*, and the format to match how it represents *stored* messages — but they typically pair up as below.
+
+| Your backend streams… | Stream adapter | Pair with message format |
+|------------------------------------------------------------------------|---------------------------------|--------------------------------------|
+| OpenAI **Chat Completions** SSE (`data: {…}` lines) | `openAIAdapter()` | `openAIMessageFormat` |
+| OpenAI SDK `stream.toReadableStream()` (NDJSON, no `data:` prefix) | `openAIReadableStreamAdapter()` | `openAIMessageFormat` |
+| OpenAI **Responses / Conversations** API SSE | `openAIResponsesAdapter()` | `openAIConversationMessageFormat` |
+| **LangGraph** named-event SSE (`event: messages\ndata: …`) | `langGraphAdapter()` | `langGraphMessageFormat` |
+| Already AG-UI events (your route emits AG-UI SSE directly) | `agUIAdapter()` | `identityMessageFormat` (default) |
+
+Wrapping Anthropic, or any provider with no bundled adapter? You have two paths: translate to AG-UI events server-side and use `agUIAdapter()`, or re-emit your provider's stream as OpenAI Completions SSE and use `openAIAdapter()`. See [Migrating](/docs/agent/guides/migrating).
+
+### `agUIAdapter`
+
+```ts
+import { agUIAdapter } from "@openuidev/react-ui";
+
+const llm = fetchLLM({ url: "/api/chat", streamAdapter: agUIAdapter() });
+```
+
+- **Wire shape:** Server-Sent Events whose `data:` payload is already a serialized AG-UI event (`data: {"type":"TEXT_MESSAGE_CONTENT",…}`). `[DONE]` sentinels and blank lines are ignored.
+- **When to use:** Your route does the provider translation itself and emits AG-UI events directly. This is the lowest-overhead option when you control the backend — no per-provider parsing in the browser. It pairs naturally with `identityMessageFormat`, since both sides already speak AG-UI.
+
+### `openAIAdapter`
+
+```ts
+import { openAIAdapter } from "@openuidev/react-ui";
+
+const llm = fetchLLM({
+ url: "/api/chat",
+ streamAdapter: openAIAdapter(),
+ messageFormat: openAIMessageFormat,
+});
+```
+
+- **Wire shape:** OpenAI **Chat Completions** streaming SSE — the raw `data: {…}` chunk format from `chat.completions.create({ stream: true })` (each line is a `ChatCompletionChunk`).
+- **What it emits:** `TEXT_MESSAGE_START` on the first content/role delta, `TEXT_MESSAGE_CONTENT` per content delta, `TOOL_CALL_START` / `TOOL_CALL_ARGS` for streamed tool calls (indexed by `tool_calls[].index`), and end events driven by `finish_reason` (`stop` → `TEXT_MESSAGE_END`, `tool_calls` → `TOOL_CALL_END` for each open call).
+- **When to use:** Your route forwards the OpenAI Completions stream as-is (the most common OpenAI setup). Pair with `openAIMessageFormat`.
+
+### `openAIReadableStreamAdapter`
+
+```ts
+import { openAIReadableStreamAdapter } from "@openuidev/react-ui";
+
+const llm = fetchLLM({
+ url: "/api/chat",
+ streamAdapter: openAIReadableStreamAdapter(),
+ messageFormat: openAIMessageFormat,
+});
+```
+
+- **Wire shape:** **NDJSON** — one JSON object per line with no `data:` SSE prefix. This is exactly what the OpenAI SDK's `Stream.toReadableStream()` produces.
+- **When to use:** Your route returns `stream.toReadableStream()` from the OpenAI SDK directly, instead of re-serializing it as SSE. Same Completions chunk semantics as `openAIAdapter()` (same emitted events), only the line framing differs — it buffers partial lines across chunks rather than splitting on `data: `. Pair with `openAIMessageFormat`.
+
+`openAIAdapter()` and `openAIReadableStreamAdapter()` read the same OpenAI chunk objects — they differ only in framing (`data:` SSE vs. bare NDJSON lines). Match the one your route actually writes; using the wrong one will fail to parse every line.
+
+### `openAIResponsesAdapter`
+
+```ts
+import { openAIResponsesAdapter } from "@openuidev/react-ui";
+
+const llm = fetchLLM({
+ url: "/api/chat",
+ streamAdapter: openAIResponsesAdapter(),
+ messageFormat: openAIConversationMessageFormat,
+});
+```
+
+- **Wire shape:** OpenAI **Responses API** (and Conversations API) streaming SSE — the item-based `ResponseStreamEvent` stream from `responses.create({ stream: true })`.
+- **What it handles:** `response.output_item.added` (assistant message start, `function_call` start, and server-side `function_call_output` → `TOOL_CALL_RESULT`), `response.output_text.delta` / `.done` for text, `response.function_call_arguments.delta` / `.done` for tool-call arguments (mapping `item_id` → `call_id`), and `error` / `response.failed` → `RUN_ERROR`. Lifecycle/metadata events (`response.created`, `response.completed`, etc.) are ignored.
+- **When to use:** You call the newer Responses or Conversations API rather than Chat Completions. Pair with `openAIConversationMessageFormat`.
+
+### `langGraphAdapter`
+
+```ts
+import { langGraphAdapter } from "@openuidev/react-ui";
+
+const llm = fetchLLM({
+ url: "/api/langgraph",
+ streamAdapter: langGraphAdapter({
+ onInterrupt: (payload) => {
+ // Called when a LangGraph __interrupt__ appears in an `updates` event.
+ console.log("interrupt", payload);
+ },
+ }),
+ messageFormat: langGraphMessageFormat,
+});
+```
+
+- **Wire shape:** LangGraph **named-event** SSE — `event: \ndata: \n\n` blocks, in the `messages` stream mode. Handles the `messages`, `metadata`, `updates`, `error`, and `end` event types; other event types (`values`, `debug`, `tasks`, `checkpoints`, `custom`) are ignored.
+- **What it handles:** AI message chunks → `TEXT_MESSAGE_*`; both streamed (`tool_call_chunks`) and complete (`tool_calls`) tool calls → `TOOL_CALL_*`; `error` events → `RUN_ERROR`. It closes out any open message/tool calls on the `end` event (or at stream end if no `end` arrives).
+- **Options** (`LangGraphAdapterOptions`):
+ - `onInterrupt?: (interrupt: unknown) => void` — invoked with the `__interrupt__` payload when a LangGraph interrupt surfaces in an `updates` event. This is the only adapter that takes options.
+- **When to use:** Your backend streams from a LangGraph deployment. Pair with `langGraphMessageFormat`.
+
+### AG-UI event vocabulary
+
+Every stream adapter normalizes its provider's stream into the same set of **AG-UI events** — the agent↔UI event protocol OpenUI streams internally (from the `@ag-ui/core` package). These events progressively build the assistant message as they arrive.
+
+| Event | Role |
+|-------|------|
+| `TEXT_MESSAGE_START` | Begins an assistant text message. |
+| `TEXT_MESSAGE_CONTENT` | Appends a chunk of text to the in-progress message. |
+| `TEXT_MESSAGE_END` | Closes the assistant text message. |
+| `TOOL_CALL_START` | Begins a tool call (carries the tool name / call id). |
+| `TOOL_CALL_ARGS` | Appends a chunk of the tool call's streamed arguments. |
+| `TOOL_CALL_END` | Closes the tool call's arguments. |
+| `TOOL_CALL_RESULT` | Carries the result of a server-executed tool call. |
+| `RUN_ERROR` | Signals a provider/run-level failure; surfaces as an error in the UI. |
+
+---
+
+## Message formats
+
+A **message format** converts between the canonical `Message` type at the boundary and your provider's message shape, in both directions. There is one per provider *message shape*:
+
+```ts
+interface MessageFormat {
+ /** Canonical Message[] → your provider's shape (outbound). */
+ toApi(messages: Message[]): unknown;
+ /** Your provider/storage shape → canonical Message[] (inbound). */
+ fromApi(data: unknown): Message[];
+}
+```
+
+Both methods operate on **arrays**, so formats where one canonical message maps to several provider items (e.g. the Responses API, where tool calls are sibling items of the assistant message) work the same as 1-to-1 formats.
+
+Where formats are used:
+
+- **`fetchLLM`** runs `messageFormat.toApi(messages)` on the outgoing request body. (The reply is parsed by the *stream adapter*, not the message format.)
+- **`restStorage`** uses `toApi` when creating a thread and `fromApi` when loading a thread's messages — so the same format keeps your stored history and your live requests consistent.
+
+If you omit `messageFormat`, both default to `identityMessageFormat`.
+
+### `identityMessageFormat` (default)
+
+```ts
+import { identityMessageFormat } from "@openuidev/react-ui";
+```
+
+- **Conversion:** none — messages pass through as-is in canonical AG-UI form (`toApi` returns the array unchanged; `fromApi` casts it back to `Message[]`).
+- **When to use:** Your backend already speaks the canonical `Message` shape. This is the default, so you can simply omit `messageFormat`. Natural partner for `agUIAdapter()`.
+
+### `openAIMessageFormat`
+
+```ts
+import { openAIMessageFormat } from "@openuidev/react-ui";
+```
+
+- **Shape:** OpenAI **Chat Completions** messages (`ChatCompletionMessageParam[]`). 1-to-1: each canonical message becomes exactly one Completions message and vice versa.
+- **Conversions:** strips/regenerates `id`; `toolCalls` ↔ `tool_calls`; `toolCallId` ↔ `tool_call_id`; multipart user `content` ↔ OpenAI content parts (binary parts become `image_url`). `reasoning`/`activity` messages have no Completions equivalent and map to an empty `system` message outbound.
+- **When to use:** Pair with `openAIAdapter()` or `openAIReadableStreamAdapter()`.
+
+### `openAIConversationMessageFormat`
+
+```ts
+import { openAIConversationMessageFormat } from "@openuidev/react-ui";
+```
+
+- **Shape:** OpenAI's item-based format for the **Responses API** and **Conversations API**. `toApi` returns `ResponseInputItem[]` (accepted by both `responses.create({ input })` and `conversations.items.create({ items })`); `fromApi` reads `ConversationItem[]` / `ResponseItem[]`.
+- **Conversions:** assistant messages are *flattened* into sibling items — a text `message` plus one `function_call` item per tool call; tool results become `function_call_output` items. Inbound, adjacent assistant `message` + `function_call` items are regrouped into one `AssistantMessage`, and Conversations-specific content parts (`reasoning_text`, `summary_text`, `refusal`, …) are folded into text.
+- **When to use:** Pair with `openAIResponsesAdapter()`.
+
+### `langGraphMessageFormat`
+
+```ts
+import { langGraphMessageFormat } from "@openuidev/react-ui";
+```
+
+- **Shape:** LangChain-style messages as used by LangGraph's thread-state API — discriminated by `type` (`"human"`, `"ai"`, `"tool"`, `"system"`) rather than `role`, with snake_case fields.
+- **Conversions:** `role` ↔ `type` (`user` ↔ `human`, `assistant` ↔ `ai`); tool-call `arguments` JSON string ↔ args object; `toolCallId` ↔ `tool_call_id`; missing `id` is generated inbound. (`developer` maps to `system`.)
+- **When to use:** Pair with `langGraphAdapter()`.
+
+---
+
+## Related
+
+
+ Where llm and storage are passed.
+ How threads, messages, and persistence fit together.
+ Persisting durable artifacts and the browser.
+ Choosing an adapter for a provider without a bundled one.
+ Implementing the adapters against your own backend.
+
diff --git a/docs/content/docs/agent/reference/agentinterface-props.mdx b/docs/content/docs/agent/reference/agentinterface-props.mdx
new file mode 100644
index 000000000..2b2135a5c
--- /dev/null
+++ b/docs/content/docs/agent/reference/agentinterface-props.mdx
@@ -0,0 +1,183 @@
+---
+title: " props"
+description: Complete prop reference for AgentInterfaceProps — adapters, rendering, theme, branding, starters, routing, and children.
+---
+
+`AgentInterface` from `@openuidev/react-ui` is the only chat component you mount. It owns the full layout — sidebar, thread list, composer, routing, and the workspace rail — and you configure all of it through `AgentInterfaceProps`. This page is the complete prop reference, grouped by what each prop controls, with links to the relevant concept and how-to pages for each area.
+
+The only required prop is `llm`. Everything else is optional and falls back to a sensible default.
+
+## At a glance
+
+| Prop | Type | Required | Default |
+|-----------------------|---------------------------------------------------|----------|-------------------------------|
+| `llm` | `ChatLLM` | **Yes** | — |
+| `storage` | `ChatStorage` | No | internal in-memory (ephemeral)|
+| `artifactRenderers` | `ReadonlyArray>` | No | none |
+| `artifactCategories` | `ArtifactCategory[]` | No | none (single "Artifacts" item)|
+| `componentLibrary` | `Library` | No | none (markdown rendering) |
+| `components` | `{ AssistantMessage?; UserMessage? }` | No | none |
+| `theme` | `ThemeProps` | No | built-in theme |
+| `disableThemeProvider`| `boolean` | No | `false` |
+| `logoUrl` | `string` | No | none |
+| `agentName` | `string` | No | none |
+| `starters` | `ConversationStarterProps[]` | No | none |
+| `starterVariant` | `"short" \| "long"` | No | — |
+| `path` | `string` | No | — (uncontrolled) |
+| `defaultPath` | `string` | No | thread view (`undefined`) |
+| `onNavigate` | `(next: string \| undefined) => void` | No | — (uncontrolled) |
+| `children` | `ReactNode` | No | none |
+
+The rest of this page explains each group.
+
+## Adapters
+
+These two props connect `AgentInterface` to your backend. They are independent channels: `llm` produces replies, `storage` persists threads and artifacts.
+
+| Prop | Type | Required | Default | Description |
+|-----------|---------------|----------|--------------------------------|------------------------------------------------------------------------|
+| `llm` | `ChatLLM` | **Yes** | — | Sends messages to your backend and streams the reply. |
+| `storage` | `ChatStorage` | No | internal in-memory (ephemeral) | Persists thread history (and, optionally, artifacts). |
+
+`llm` is a `ChatLLM` — usually built with `fetchLLM()`, which POSTs `{ threadId, messages }` to your route and parses the streamed response. See [Conversations](/docs/agent/core-concepts/conversations) for the model, and the [Adapters & formats reference](/docs/agent/reference/adapters-and-formats) for the full `ChatLLM` / `fetchLLM()` surface and the stream adapters that parse each provider's stream.
+
+`storage` is a `ChatStorage` — `{ thread: ThreadStorage; artifact?: ArtifactStorage }`. Omit it and conversations live only in memory: a page refresh clears everything. Build one with `restStorage()`, or implement the interfaces yourself. The full adapter interfaces (`ChatLLM`, `ChatStorage`, `ThreadStorage`, `ArtifactStorage`, `fetchLLM`, `restStorage`) are in the [Adapters & formats reference](/docs/agent/reference/adapters-and-formats).
+
+The provider API call always happens on your server. `fetchLLM()` only ever talks to your own route, so provider keys never reach the browser.
+
+## Rendering
+
+These props control how the assistant's output is rendered — both durable artifacts (dashboards, reports, presentations, apps) and the inline message body.
+
+| Prop | Type | Required | Default | Description |
+|----------------------|-----------------------------------------------|----------|---------|-----------------------------------------------------------------------------|
+| `artifactRenderers` | `ReadonlyArray>` | No | none | Renderers that display artifacts (from a tool call or from stored content). |
+| `artifactCategories` | `ArtifactCategory[]` | No | none | User-defined groupings for the artifact browser and workspace rail. |
+| `componentLibrary` | `Library` | No | none | Turns on Generative UI — rich interactive components rendered inline in chat.|
+| `components` | `{ AssistantMessage?; UserMessage? }` | No | none | Replace the assistant/user message components entirely. |
+
+### `artifactRenderers`
+
+Each entry is built with `defineArtifactRenderer` and links a tool name (or names) to a `preview` (inline in the message) and an `actual` (full view in the workspace panel or a full-page artifact view). The registry indexes by both `toolName` and `type`. See [Artifacts](/docs/agent/core-concepts/artifacts) and the [`defineArtifactRenderer` reference](/docs/agent/reference/define-artifact-renderer).
+
+```tsx
+import { AgentInterface, defineArtifactRenderer } from "@openuidev/react-ui";
+
+const codeArtifact = defineArtifactRenderer({
+ type: "code_artifact",
+ toolName: "create_code_artifact",
+ parser: (raw, ctx) => /* ... */ null,
+ preview: (props, controls) => /* ... */ null,
+ actual: (props, controls) => /* ... */ null,
+});
+
+ ;
+```
+
+### `artifactCategories`
+
+Categories are **user-defined organization** for the artifact browser — they are orthogonal to whether an artifact is static or live. Each is `{ name: string; filter: { type: string[] } }`. Without categories, the sidebar shows a single "Artifacts" item; with them, one item per category. See [Artifacts](/docs/agent/core-concepts/artifacts).
+
+```tsx
+ ;
+```
+
+### `componentLibrary`
+
+Pass a library (e.g. `openuiLibrary` from `@openuidev/react-ui/genui-lib`) to enable Generative UI: the LLM renders rich, interactive components **inline in chat messages**, beyond markdown. This is distinct from artifacts (durable outputs in panels and pages). See [Generative UI](/docs/agent/core-concepts/generative-ui).
+
+```tsx
+import { AgentInterface } from "@openuidev/react-ui";
+import { openuiLibrary } from "@openuidev/react-ui/genui-lib";
+
+ ;
+```
+
+### `components`
+
+Override the message components directly: `{ AssistantMessage?, UserMessage? }`. Message rendering precedence is **`components` > `componentLibrary` > built-in default** — an explicit `components` entry always wins. See [Message rendering](/docs/agent/customize/message-rendering).
+
+## Theme
+
+| Prop | Type | Required | Default | Description |
+|------------------------|--------------|----------|----------------|---------------------------------------------------------------------|
+| `theme` | `ThemeProps` | No | built-in theme | Customizes colors, radii, and typography. |
+| `disableThemeProvider` | `boolean` | No | `false` | Skip the built-in theme provider when your app already supplies one.|
+
+Set `disableThemeProvider` to `true` when `AgentInterface` is mounted inside an app that already wraps it in a compatible theme provider, to avoid nesting two.
+
+## Branding
+
+| Prop | Type | Required | Default | Description |
+|-------------|----------|----------|---------|----------------------------------------------------------|
+| `logoUrl` | `string` | No | none | Logo shown in the sidebar header and the mobile header. |
+| `agentName` | `string` | No | none | Agent name shown next to the logo. |
+
+These feed the default `SidebarHeader` and `MobileHeader`. To go further, replace those slots. See [Sidebar](/docs/agent/customize/sidebar).
+
+## Starters
+
+Conversation starters are the suggested prompts shown on the welcome screen and in the composer.
+
+| Prop | Type | Required | Default | Description |
+|------------------|-------------------------------|----------|---------|--------------------------------------------------------------|
+| `starters` | `ConversationStarterProps[]` | No | none | Suggested prompts to seed a conversation. |
+| `starterVariant` | `"short" \| "long"` | No | — | Layout variant for how starters are presented. |
+
+Each starter is `{ displayText: string; prompt: string; icon?: ReactNode }` — `displayText` is the label on the suggestion chip and `prompt` is the message sent when it is clicked. These props set the defaults for the `Welcome` and `Composer` slots; either slot can also take its own `starters` / `starterVariant`. See [Welcome & starters](/docs/agent/customize/welcome-and-starters).
+
+## Routing
+
+`AgentInterface` has a built-in router. `undefined` is the thread view; non-`undefined` paths select Routes and the artifact browser.
+
+| Prop | Type | Required | Default | Description |
+|---------------|----------------------------------------|----------|--------------------------|--------------------------------------------------------------|
+| `path` | `string` | No | — (uncontrolled) | Controlled current route. |
+| `defaultPath` | `string` | No | thread view (`undefined`)| Uncontrolled initial route. |
+| `onNavigate` | `(next: string \| undefined) => void` | No | — (uncontrolled) | Navigation callback; **its presence selects controlled mode**.|
+
+Controlled mode is chosen by the **presence of `onNavigate`**, not by `path !== undefined` — because `undefined` is a meaningful path (the thread view). In controlled mode, drive `path` from your own router and update it inside `onNavigate`. In uncontrolled mode, set an initial route with `defaultPath` and let the component manage navigation internally.
+
+Controlled consumers must round-trip the reserved artifact-browser paths (`artifacts/{category}` and `artifacts/{category}/{id}`), which are matched before your own `AgentInterface.Route` entries. Read the current route and navigate from anywhere inside the component with the `useNav()` hook (from `@openuidev/react-ui`).
+
+```tsx
+// Uncontrolled: open on a specific route at mount.
+ ;
+
+// Controlled: drive the route from your own state/router.
+ ;
+```
+
+## Children
+
+| Prop | Type | Required | Default | Description |
+|------------|-------------|----------|---------|---------------------------------------------------|
+| `children` | `ReactNode` | No | none | Slots, primitives, Routes, and free content. |
+
+`children` is where composition happens. Pass the compound statics on `AgentInterface` — slots (`AgentInterface.Sidebar`, `.Welcome`, `.Composer`, `.Workspace`, …), primitives (`AgentInterface.SidebarItem`, `.ThreadList`, `.Messages`, …), and `AgentInterface.Route` for multi-page content. Slots not provided fall back to their built-in defaults. See the [Components reference](/docs/agent/reference/components).
+
+```tsx
+
+
+
+
+
+ ;
+```
+
+## Related references
+
+- [Adapters & formats](/docs/agent/reference/adapters-and-formats) — `ChatLLM`, `ChatStorage`, `ThreadStorage`, `ArtifactStorage`, `fetchLLM`, `restStorage`, the stream adapters, and the message formats used to build `llm` and `storage`.
+- [`defineArtifactRenderer`](/docs/agent/reference/define-artifact-renderer) — the shape of each `artifactRenderers` entry.
+- [Hooks](/docs/agent/reference/hooks) — `useNav`, `useThread`, `useArtifactStorage`, and the rest.
+- [Components](/docs/agent/reference/components) — the slots and primitives you pass as `children`.
diff --git a/docs/content/docs/agent/reference/components.mdx b/docs/content/docs/agent/reference/components.mdx
new file mode 100644
index 000000000..71b7c346a
--- /dev/null
+++ b/docs/content/docs/agent/reference/components.mdx
@@ -0,0 +1,346 @@
+---
+title: Components
+description: Reference for the slots and primitive statics on AgentInterface — Sidebar, ThreadHeader, Welcome, Composer, Workspace, SidebarItem, Route, and the rest.
+---
+
+Every customizable region of `AgentInterface` is exposed as a **compound static** — a component hung off `AgentInterface` (e.g. `AgentInterface.Welcome`, `AgentInterface.SidebarItem`). You compose the interface by placing these as children:
+
+```tsx
+import { AgentInterface } from "@openuidev/react-ui";
+
+
+
+
+
+ }>
+ Settings
+
+
+
+
+
+```
+
+There are two kinds:
+
+- **Slots** — named regions of the layout (`Sidebar`, `Welcome`, `Composer`, …). `AgentInterface` matches them **by component reference** (not by position or string), routes each into the right region, and treats anything unrecognized as free content. Order doesn't matter. Slots support a subset of three override modes:
+ - **A — omit:** the built-in default renders.
+ - **B — props:** the default structure renders with your values swapped into specific pieces.
+ - **C — children:** your children replace the region entirely (children win over props; a dev warning fires if you pass both).
+- **Primitives / statics** — building blocks you place *inside* slots (or, for `Route`, at the top level) to assemble custom layouts: `SidebarItem`, `SidebarContent`, `SidebarSeparator`, `ArtifactNav`, `Route`, `NewChatButton`, `ThreadList`, `Messages`, `MessageLoading`, `ScrollArea`.
+
+All of these are also reachable as named exports from `@openuidev/react-ui`, but the compound-static form (`AgentInterface.X`) is the documented usage.
+
+## Slots
+
+Pass these at the top level of `` to override a layout region. The **Modes** column lists which of the three override modes (A — omit, B — props, C — children) each slot supports.
+
+| Slot | Region | Modes |
+|------|--------|-------|
+| `AgentInterface.Sidebar` | The entire left sidebar | A, C |
+| `AgentInterface.SidebarHeader` | Header at the top of the sidebar | A, B, C |
+| `AgentInterface.MobileHeader` | Top bar shown on mobile in place of the sidebar | A, B, C |
+| `AgentInterface.ThreadHeader` | Header above the active conversation | A, C |
+| `AgentInterface.Welcome` | Empty-state screen shown before a thread starts | A, B, C |
+| `AgentInterface.Composer` | Message input at the bottom of the thread | A, B, C |
+| `AgentInterface.Workspace` | Per-thread right rail listing the thread's artifacts | A, C |
+
+### `AgentInterface.Sidebar`
+
+The whole left sidebar. **Modes A, C.**
+
+- **A — omit:** the default sidebar renders (`SidebarHeader` + `SidebarContent` containing `ArtifactNav` when `storage.artifact` is configured, a `SidebarSeparator`, and `ThreadList`).
+- **C — children:** your children replace the sidebar's inner content. You compose `SidebarHeader`, `SidebarItem`, `SidebarSeparator`, `ThreadList`, etc. as you like.
+
+```tsx
+
+
+
+ Dashboard
+
+
+
+
+```
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `children` | `ReactNode` | Mode C — replaces the sidebar's inner content. |
+
+
+When you provide ``, a `` placed at the **top level** (outside `Sidebar`) is ignored with a dev warning — put it inside `Sidebar`.
+
+
+### `AgentInterface.SidebarHeader`
+
+The header strip at the top of the sidebar — brand logo, agent name, and the collapse button. **Modes A, B, C.**
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `logo` | `ReactNode` | Defaults to `logoUrl` from ``. |
+| `agentName` | `ReactNode` | Defaults to `agentName` from ``. |
+| `collapseButton` | `ReactNode \| false` | Pass `false` to hide the collapse control. |
+| `children` | `ReactNode` | Mode C — replaces the header body. |
+| `className` | `string` | |
+
+### `AgentInterface.MobileHeader`
+
+The top bar shown on mobile viewports in place of the (collapsed) sidebar. **Modes A, B, C.**
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `logo` | `ReactNode` | |
+| `agentName` | `ReactNode` | |
+| `menuButton` | `ReactNode \| false` | Opens the mobile sidebar drawer. Pass `false` to hide. |
+| `newChatButton` | `ReactNode \| false` | Pass `false` to hide. |
+| `actions` | `ReactNode` | Extra controls, right-aligned. |
+| `children` | `ReactNode` | Mode C — replaces the header. |
+| `className` | `string` | |
+
+### `AgentInterface.ThreadHeader`
+
+Header bar above the active conversation. **Modes A, C.**
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `children` | `ReactNode` | Mode C — replaces the header. |
+| `className` | `string` | |
+
+### `AgentInterface.Welcome`
+
+The empty-state screen shown before the first message of a thread. **Modes A, B, C.** As with every slot, `children` (mode C) win over the content props (mode B) and a dev warning fires if you pass both.
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `title` | `string` | Greeting/title text. |
+| `description` | `string` | Sub-text under the title. |
+| `image` | `{ url: string } \| ReactNode` | `{ url }` renders a styled 64×64 rounded ` `; a `ReactNode` is rendered as-is. |
+| `starters` | `ConversationStarterProps[]` | Conversation starters. Inherits from `` when omitted. |
+| `starterVariant` | `"short" \| "long"` | Layout for the starters. Inherits from ``. |
+| `children` | `ReactNode` | Mode C — replaces the screen; when passed, `title`/`description`/`image`/`starters` are ignored (dev warning). |
+| `className` | `string` | |
+
+```tsx
+
+```
+
+Each entry in `starters` is a `ConversationStarterProps`: `{ displayText: string; prompt: string; icon?: ReactNode }`. See [Welcome & starters](/docs/agent/customize/welcome-and-starters) for the full guide.
+
+### `AgentInterface.Composer`
+
+The message input at the bottom of the thread. **Modes A, B, C.**
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `placeholder` | `string` | Input placeholder. |
+| `starters` | `ConversationStarterProps[]` | Starter chips shown above the input when the chat is empty. Inherits from ``. |
+| `starterVariant` | `"short" \| "long"` | Inherits from ``. |
+| `children` | `ReactNode` | Mode C — fully replaces the composer area; auto-starter rendering is then disabled. |
+| `className` | `string` | |
+
+### `AgentInterface.Workspace`
+
+The per-thread right rail listing the artifacts registered in the active thread. **Modes A, C.**
+
+- **Auto-shows on the first registered artifact** and renders nothing while the thread's registry is empty — drop-in users with no artifact renderers never see it.
+- Sections are driven by the `artifactCategories` configured on ``; a single "Artifacts" section lists everything when no categories are configured.
+- Clicking an item opens the corresponding artifact's detailed view.
+- Rendered **only in the thread view** — hidden on `Route` pages and the artifact browser. Hidden on mobile.
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `children` | `ReactNode` | Mode C — replaces the entire rail (you own its chrome and visibility). |
+| `className` | `string` | |
+
+See [Artifacts](/docs/agent/core-concepts/artifacts) for the artifact model the Workspace surfaces.
+
+## Primitives & statics
+
+Place these inside slots — or, for `Route`, at the top level — to build custom layouts. Unlike slots, primitives are not matched by reference into a region; they render wherever you put them.
+
+| Primitive | Role |
+|-----------|------|
+| `AgentInterface.SidebarContent` | Scrollable body region of the sidebar. |
+| `AgentInterface.SidebarSeparator` | Styled divider between sidebar groups. |
+| `AgentInterface.SidebarItem` | Clickable nav row, optionally path-bound. |
+| `AgentInterface.ArtifactNav` | Sidebar nav into the global artifact browser. |
+| `AgentInterface.Route` | Top-level exact-path view that replaces the thread region. |
+| `AgentInterface.NewChatButton` | Button that starts a fresh thread. |
+| `AgentInterface.ThreadList` | List of the user's threads from `storage.thread`. |
+| `AgentInterface.Messages` | The active thread's messages + streaming message. |
+| `AgentInterface.MessageLoading` | Default "assistant is thinking" indicator. |
+| `AgentInterface.ScrollArea` | Bottom-pinned scroll container for thread content. |
+
+### `AgentInterface.SidebarContent`
+
+The scrollable body region of the sidebar — where nav items and the thread list live. Place it inside `` to wrap your sidebar content.
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `children` | `ReactNode` | The content to render. |
+| `className` | `string` | |
+
+### `AgentInterface.SidebarSeparator`
+
+A styled divider rule for visually grouping sidebar sections. Takes no props; drop it between groups inside the sidebar.
+
+```tsx
+Reports
+
+
+```
+
+### `AgentInterface.SidebarItem`
+
+A styled clickable row for use inside the sidebar. Visually matches `ThreadList` rows so custom nav blends in with the thread list. Spreads remaining props onto the underlying ``.
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `icon` | `ReactNode` | Leading icon. |
+| `trailing` | `ReactNode` | Right-aligned trailing content (badges, counts). |
+| `path` | `string` | When set, clicking calls `navigate(path)`; the item auto-selects when the current path matches. Works in controlled and uncontrolled ``. |
+| `selected` | `boolean` | Active state. Defaults to `currentPath === path` when `path` is set; pass explicitly to override. |
+| `children` | `ReactNode` | The label. (Required.) |
+| `...buttonProps` | `ComponentPropsWithoutRef<"button">` | `onClick`, `disabled`, `aria-*`, etc. |
+
+**Click order with `path`:** your `onClick` fires first (call `preventDefault()` to suppress navigation) → then `navigate(path)` → then the mobile sidebar closes.
+
+```tsx
+ }
+ trailing={3 }
+>
+ Settings
+
+```
+
+Pair with [`AgentInterface.Route`](#agentinterfaceroute) to render a view at that path. See [Sidebar](/docs/agent/customize/sidebar).
+
+### `AgentInterface.ArtifactNav`
+
+Sidebar navigation for the global artifact browser. Renders one `SidebarItem` per configured `artifactCategories` entry — or a single "Artifacts" item when no categories are configured. Each item navigates to the reserved `artifacts/{category}` path, which `AgentInterface` renders as the searchable artifact browser in the thread region.
+
+- **Included automatically** in the default sidebar when `storage.artifact` is configured.
+- **Renders nothing** when `storage.artifact` is not configured.
+- When you build a custom ``, add it back manually where you want it.
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `icon` | `ReactNode` | Leading icon for every category item. Defaults to a boxes icon. |
+| `className` | `string` | |
+
+```tsx
+
+
+
+
+
+
+
+
+```
+
+See [Artifacts](/docs/agent/core-concepts/artifacts).
+
+### `AgentInterface.Route`
+
+Declares a routable view. Placed at the **top level** of `` (not inside a slot). When the current nav path **exactly matches** `path`, the Route's children **replace the entire thread region** — `MobileHeader`, `ThreadHeader`, the message scroll area, and `Composer` are all hidden. When no Route matches, the thread region renders normally.
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `path` | `string` | Exact string match — **no wildcards or params**. |
+| `children` | `ReactNode` | Content shown in the thread region when active. |
+
+```tsx
+
+ Settings
+
+
+
+
+```
+
+Use multiple `` siblings to define separate views. The paths `artifacts/{category}` and `artifacts/{category}/{id}` are reserved by the artifact browser and matched before your Routes.
+
+### `AgentInterface.NewChatButton`
+
+A styled button that starts a fresh thread (clears the active thread and returns to the empty/welcome state). Drop it anywhere inside the sidebar or a custom header.
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `className` | `string` | |
+
+```tsx
+
+
+```
+
+### `AgentInterface.ThreadList`
+
+Renders the list of the user's threads from `storage.thread` (one row per thread). Clicking a row opens that thread and **auto-clears the current path** (returning to the thread view from any Route or the artifact browser).
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `className` | `string` | |
+
+```tsx
+
+
+
+```
+
+### `AgentInterface.Messages`
+
+Renders the active thread's messages, including the in-flight streaming message and the loading indicator. Used inside a custom thread region (typically wrapped in `ScrollArea`).
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `loader` | `ReactNode` | Custom loading indicator shown while a response is pending. Defaults to `MessageLoading`. |
+| `assistantMessage` | component | Override for rendering assistant messages. Mirrors `components.AssistantMessage` on ``. |
+| `userMessage` | component | Override for rendering user messages. Mirrors `components.UserMessage`. |
+| `className` | `string` | |
+
+```tsx
+
+ } />
+
+```
+
+For full control over per-message rendering, see [Message rendering](/docs/agent/customize/message-rendering).
+
+### `AgentInterface.MessageLoading`
+
+The default "assistant is thinking" indicator. Render it standalone, or pass it (or your own) to `Messages` via the `loader` prop. Takes no props.
+
+```tsx
+ } />
+```
+
+### `AgentInterface.ScrollArea`
+
+A scroll container that keeps the conversation pinned to the bottom as messages stream in. Wrap `Messages` (or any thread content) in it when composing a custom thread region.
+
+| Prop | Type | Notes |
+|------|------|-------|
+| `scrollVariant` | `ScrollVariant` | Controls the scroll-to-bottom behavior when the last message is added. |
+| `userMessageSelector` | `string` | CSS selector identifying the user message element (used to anchor scrolling). |
+| `children` | `ReactNode` | Scrollable content. |
+| `className` | `string` | |
+
+```tsx
+
+
+
+```
+
+## See also
+
+- [Sidebar](/docs/agent/customize/sidebar) — building custom sidebars with `SidebarItem`, `ArtifactNav`, and `ThreadList`.
+- [`` props](/docs/agent/reference/agentinterface-props) — the top-level props (`logoUrl`, `agentName`, `starters`, `path`/`onNavigate`, …) that these components inherit from.
diff --git a/docs/content/docs/agent/reference/define-artifact-renderer.mdx b/docs/content/docs/agent/reference/define-artifact-renderer.mdx
new file mode 100644
index 000000000..3eb5e6b80
--- /dev/null
+++ b/docs/content/docs/agent/reference/define-artifact-renderer.mdx
@@ -0,0 +1,294 @@
+---
+title: defineArtifactRenderer
+description: Full reference for the artifact-renderer config — type, toolName, the parser contract, preview/actual, controls, and registry matching.
+---
+
+A **renderer** describes how one kind of artifact — a dashboard, a report, a slide deck, a code file — turns into UI: an inline preview inside the chat message and a full view in a side panel or page. You build one with `defineArtifactRenderer(config)` and pass the results to `` through the [`artifactRenderers`](/docs/agent/reference/agentinterface-props#artifactrenderers) prop.
+
+```tsx
+import { defineArtifactRenderer } from "@openuidev/react-ui";
+
+const codeArtifactRenderer = defineArtifactRenderer({
+ type: "code_artifact",
+ toolName: "create_code_artifact",
+ parser: ({ response }) => {
+ const data = response as CodeArtifact | null;
+ if (!data) return null;
+ return { props: data, meta: { id: `code:${data.title}`, version: 1, heading: data.title } };
+ },
+ preview: (props, controls) => ,
+ actual: (props) => ,
+});
+```
+
+`defineArtifactRenderer` returns its argument unchanged. Its only job is type inference: it infers the `Props` type from the `parser`'s return value, so `preview` and `actual` receive a fully-typed `props` without you annotating anything by hand. (Without it you'd have to write `const r: ArtifactRendererConfig = { ... }`.)
+
+For a walkthrough of building a renderer end to end, see [Custom artifacts](/docs/agent/guides/custom-artifacts). This page is the precise contract.
+
+## Config type
+
+```ts
+function defineArtifactRenderer(
+ config: ArtifactRendererConfig,
+): ArtifactRendererConfig;
+
+interface ArtifactRendererConfig {
+ type: string;
+ toolName: string | string[];
+ parser: (
+ raw: { args: unknown; response: unknown },
+ ctx: { isStreaming: boolean },
+ ) => ParsedArtifact | null;
+ preview: (props: Props, controls: ArtifactRendererControls) => ReactNode;
+ actual: (props: Props, controls: ArtifactRendererControls) => ReactNode;
+}
+
+interface ParsedArtifact {
+ props: Props;
+ meta: { id: string; version: number; heading: string } | null;
+}
+
+interface ArtifactRendererControls {
+ isActive: boolean;
+ isStreaming: boolean;
+ open: () => void;
+ close: () => void;
+ toggle: () => void;
+}
+```
+
+All five fields (`type`, `toolName`, `parser`, `preview`, `actual`) are required.
+
+| Field | Type | Purpose |
+| --- | --- | --- |
+| `type` | `string` | The artifact kind — the key for the storage path and for category filters. |
+| `toolName` | `string \| string[]` | The tool call(s) this renderer matches on the tool-call path. |
+| `parser` | `(raw, ctx) => ParsedArtifact \| null` | Converts the raw envelope into typed `props` plus an optional `meta` registry entry. |
+| `preview` | `(props, controls) => ReactNode` | The compact inline view rendered in the chat message. |
+| `actual` | `(props, controls) => ReactNode` | The full view rendered in the detailed-view panel or full-page browser. |
+
+## `type`
+
+```ts
+type: string;
+```
+
+The artifact kind — a literal string like `"code_artifact"` or `"code_block"`. It links three things together:
+
+- **this renderer**, for the storage path (the artifact browser looks up a renderer by `type`);
+- any [`artifactCategories`](/docs/agent/reference/agentinterface-props#artifactcategories) whose `filter.type` includes it;
+- **stored artifacts**, whose `ArtifactSummary.type` is matched against it.
+
+`type` is orthogonal to the static/live distinction — it is just an identifier you choose. On a duplicate `type` across renderers, the first registration wins (see [Registry behavior](#registry-behavior)).
+
+## `toolName`
+
+```ts
+toolName: string | string[];
+```
+
+The tool call(s) this renderer matches on the tool-call path. Literal strings only — **no `RegExp`, no wildcards, no patterns**. A single string matches one tool; an array registers the *same* renderer for several tool names (e.g. a create and an edit tool that both yield the same artifact):
+
+```ts
+toolName: ["presentation:create", "presentation:edit"],
+```
+
+On a duplicate `toolName` across renderers, the first registration wins.
+
+## `parser`
+
+```ts
+parser: (
+ raw: { args: unknown; response: unknown },
+ ctx: { isStreaming: boolean },
+) => { props: Props; meta: { id; version; heading } | null } | null;
+```
+
+The parser converts the raw envelope into typed `props` plus an optional `meta` registry entry. The SDK **does not pre-parse JSON** — `args` and `response` arrive exactly as the backend emitted them (typically a JSON *string* for `args`, the raw deserialized value for `response`, depending on your stream adapter). Your parser owns deserialization and validation.
+
+### Two invocation paths
+
+The same parser is called on both paths, with different inputs:
+
+| | `args` | `response` | `ctx.isStreaming` |
+| --- | --- | --- | --- |
+| **Tool-call path** (matched by `toolName`) | the call's arguments, as emitted | the tool result, or `null` until it arrives | `true` while args stream in; `false` once the result lands |
+| **Storage path** (matched by `type`) | `undefined` | `artifact.content` | always `false` |
+
+Concretely, the storage path always calls:
+
+```ts
+parser({ args: undefined, response: artifact.content }, { isStreaming: false });
+```
+
+Two consequences your parser must respect:
+
+1. **It is called on every update during streaming.** While `ctx.isStreaming` is `true`, `args` may be a *partial* JSON string (the LLM is still emitting it) and `response` may be `null` (the tool result hasn't paired in yet). Tolerate both — wrap JSON parsing in `try/catch`, and bail with `return null` until you have enough to render.
+2. **Read everything you need out of `response`, not `args`.** Only the tool-call path populates `args`, and only the storage path guarantees a complete `response`. Treat `args` as a streaming nicety (useful for an early preview) and `response` as the source of truth. To make one renderer work on both paths, ensure whatever your tool *returns* has the same shape as whatever you *persist* as `artifact.content`.
+
+### Return value
+
+| Return | Effect |
+| --- | --- |
+| `null` (outer) | Skip rendering this call entirely — no preview, no actual, no registry entry. |
+| `{ props, meta: { id, version, heading } }` | Render `preview`/`actual` with `props`, **and** register an entry in the per-thread ThreadContext. |
+| `{ props, meta: null }` | Render `preview`/`actual` with `props`, but do **not** register in ThreadContext. |
+
+`meta` controls the [per-thread artifact registry](/docs/agent/reference/hooks#useartifactlist) — the list backing `useArtifactList` and the [Workspace](/docs/agent/reference/components#agentinterfaceworkspace) rail:
+
+- **`meta.id`** — a stable identifier for this logical entry. It should not change across re-runs of the same artifact.
+- **`meta.version`** — bump it when the content changes. When the `(id, version)` pair changes, the entry is re-registered.
+- **`meta.heading`** — the display label shown in workspace/registry lists.
+
+A common pattern is to return `meta: null` while `ctx.isStreaming` is `true`, then a real `meta` once the result arrives — so the entry only appears in the registry after the artifact is complete:
+
+```ts
+parser: ({ args, response }, { isStreaming }) => {
+ const partial = safeParse(args); // tolerate partial JSON
+ if (!partial) return null; // not enough to draw yet
+ if (isStreaming || !response) {
+ return { props: partial, meta: null }; // render a skeleton, don't register
+ }
+ const data = response as CodeArtifact;
+ return { props: data, meta: { id: `code:${data.title}`, version: 1, heading: data.title } };
+},
+```
+
+## `preview`
+
+```ts
+preview: (props: Props, controls: ArtifactRendererControls) => ReactNode;
+```
+
+The compact view rendered **inline in the chat message** — typically a card or chip with a button that opens the full view via `controls.open()`. Receives the `props` your parser produced and the [`controls`](#controls).
+
+## `actual`
+
+```ts
+actual: (props: Props, controls: ArtifactRendererControls) => ReactNode;
+```
+
+The **full view** of the artifact. It renders in one of two places depending on how the artifact was opened:
+
+- the in-thread **detailed-view panel** (the side panel opened by `controls.open()`), or
+- the **full-page artifact view** in the artifact browser (`artifacts/{category}/{id}`).
+
+Same `props`, same `controls` signature as `preview`.
+
+## `controls`
+
+Both `preview` and `actual` receive a `controls` object:
+
+```ts
+interface ArtifactRendererControls {
+ isActive: boolean; // this renderer's detailed view is the currently active one
+ isStreaming: boolean; // true while the tool call is still streaming; always false for storage-opened artifacts
+ open: () => void; // activate this renderer's detailed view
+ close: () => void; // close this renderer's detailed view if active
+ toggle: () => void; // toggle this renderer's detailed view
+}
+```
+
+| Field | Meaning |
+| --- | --- |
+| `isActive` | `true` when this renderer's detailed view is the currently active one. Lets a preview reflect that its panel is already open (e.g. highlight the card). |
+| `isStreaming` | Mirrors the `ctx.isStreaming` your parser saw — `true` while arguments arrive incrementally and no tool result has paired in, then `false` once the result lands. Always `false` for storage-opened artifacts. |
+| `open` | Activate this renderer's detailed view. |
+| `close` | Close this renderer's detailed view if active. |
+| `toggle` | Toggle this renderer's detailed view. |
+
+The **same component instance is reused** across the streaming → completed transition (no remount), so you can swap UI states off `isStreaming` — a skeleton or "streaming…" badge during partial args, then the final view once the result lands.
+
+## Registry behavior
+
+Pass an array of configs to ``. The interface builds a registry once at mount and indexes each renderer **two ways**:
+
+- **by `toolName`** — used on the tool-call path. A renderer with an array of tool names is indexed once per name.
+- **by `type`** — used on the storage path (the artifact browser rendering stored artifacts).
+
+```tsx
+
+```
+
+**First-wins on duplicates.** If two renderers register the same `toolName`, the first one in the array wins and the later one is ignored (with a dev-mode `console.warn`). The same rule applies independently to `type`. Because the rules are independent, a renderer whose `type` collides with an earlier one is still indexed under its own (non-colliding) `toolName`s — only the type-based lookup is deduped. **Put your custom renderers before any SDK defaults** so yours win.
+
+
+The registry is built once and stays stable for the provider's lifetime; later changes to the `artifactRenderers` prop are ignored (with a dev-mode warning). Pass a stable array — don't construct a fresh `artifactRenderers` value on every render expecting it to swap renderers in.
+
+
+### Looking renderers up at runtime
+
+Resolve the matched renderer for a tool name with [`useArtifactRenderer`](/docs/agent/reference/hooks#useartifactrenderer):
+
+```ts
+import { useArtifactRenderer } from "@openuidev/react-ui";
+
+const renderer = useArtifactRenderer("create_code_artifact"); // ArtifactRendererConfig | null
+```
+
+It returns `null` on a miss (and when no `artifactRenderers` were provided). This is the normal way to resolve a renderer.
+
+#### Advanced: the raw registry
+
+For custom dispatching — when you need to iterate every registered renderer, or do your own matching rather than looking up a single tool name — [`useArtifactRendererRegistry`](/docs/agent/reference/hooks#useartifactrendererregistry) is the escape hatch. It returns the whole `ArtifactRendererRegistry` (or `null` when no `artifactRenderers` were provided):
+
+```ts
+import { useArtifactRendererRegistry } from "@openuidev/react-ui";
+
+const registry = useArtifactRendererRegistry();
+// registry: { byToolName: Map;
+// byType: Map } | null
+```
+
+The two indexes mirror the [two-way indexing](#registry-behavior) above: `byToolName` is keyed on every registered tool name (a renderer with an array of tool names appears once per name) and `byType` is keyed on each renderer's `type`. The `lookupArtifactRenderer` (by tool name) and `lookupArtifactRendererByType` (by type) helpers do a single lookup against a registry object directly, without a hook. Reach for the registry only when the single-tool `useArtifactRenderer` lookup isn't enough.
+
+## Full example
+
+```tsx
+import { defineArtifactRenderer } from "@openuidev/react-ui";
+
+type CodeBlockProps = { language: string; title: string; codeString: string };
+
+const isCodeBlockProps = (v: unknown): v is CodeBlockProps =>
+ !!v &&
+ typeof v === "object" &&
+ typeof (v as CodeBlockProps).language === "string" &&
+ typeof (v as CodeBlockProps).title === "string" &&
+ typeof (v as CodeBlockProps).codeString === "string";
+
+export const codeBlockRenderer = defineArtifactRenderer({
+ type: "code_block",
+ toolName: "create_code_block",
+
+ parser: ({ args }) => {
+ if (typeof args !== "string") return null; // tolerate non-string / partial args
+ try {
+ const parsed = JSON.parse(args);
+ if (!isCodeBlockProps(parsed)) return null;
+ return {
+ props: parsed,
+ meta: { id: parsed.title, version: 1, heading: parsed.title },
+ };
+ } catch {
+ return null; // partial JSON mid-stream
+ }
+ },
+
+ preview: (props, { open, isActive }) => (
+
+ ),
+
+ actual: (props) => ,
+});
+```
+
+## See also
+
+- [Custom artifacts](/docs/agent/guides/custom-artifacts) — the build-it walkthrough, including the storage path and `artifactCategories`.
+- [Artifacts](/docs/agent/core-concepts/artifacts) — what artifacts are and where they show up.
+- [Hooks](/docs/agent/reference/hooks) — `useArtifactRenderer`, `useArtifactRendererRegistry`, `useArtifactList`.
+- [`` props](/docs/agent/reference/agentinterface-props) — `artifactRenderers`, `artifactCategories`.
+- [Components](/docs/agent/reference/components#agentinterfaceworkspace) — the Workspace rail backed by the registry.
diff --git a/docs/content/docs/agent/reference/hooks.mdx b/docs/content/docs/agent/reference/hooks.mdx
new file mode 100644
index 000000000..f44fe5dca
--- /dev/null
+++ b/docs/content/docs/agent/reference/hooks.mdx
@@ -0,0 +1,313 @@
+---
+title: Hooks
+description: Reference for every AgentInterface hook — navigation, artifact storage and registry, threads and messages, and the detailed-view system.
+---
+
+These hooks let your own components read and drive the state inside `` — the current route, the artifact storage adapter, the per-thread artifact registry, the active thread and message, and the detailed-view panel system. They're the same hooks the built-in UI uses, so anything the default chrome does, your components can do too.
+
+Two rules apply to **all** of them:
+
+- **They only work inside ``.** Each reads from a context that the component provides. Call one outside that tree and you get either a thrown error or a `null`/empty result (noted per hook below). Renderer `preview`/`actual` functions, slot children, message components, and `Route` children are all inside the tree, so all of these are valid call sites.
+- **Import from `@openuidev/react-ui`.** Every hook on this page is exported from `@openuidev/react-ui`.
+
+```tsx
+import {
+ useNav,
+ useArtifactStorage,
+ useArtifactCategories,
+ useArtifactList,
+ useArtifactRenderer,
+ useArtifactRendererRegistry,
+ useThread,
+ useThreadList,
+ useMessage,
+ useActiveDetailedView,
+ useDetailedView,
+ useDetailedViewStore,
+ useDetailedViewPortalTarget,
+ useThreadContextStore,
+} from "@openuidev/react-ui";
+```
+
+---
+
+## Navigation
+
+### `useNav`
+
+```ts
+function useNav(): {
+ path: string | undefined;
+ navigate: (next: string | undefined) => void;
+};
+```
+
+**Imported from `@openuidev/react-ui`.** Reads the current route and lets you change it. `path` is the current route string (or `undefined`, which means the thread view). `navigate(next)` moves to a route; `navigate(undefined)` returns to the thread view.
+
+In controlled mode (when you pass `onNavigate` to `AgentInterface`), `navigate` ultimately calls back through your `onNavigate` handler — the hook respects whichever mode the component is in. **Throws if called outside ``.**
+
+```tsx
+function GoToSettings() {
+ const { path, navigate } = useNav();
+ return navigate("settings")}>Settings ;
+}
+```
+
+Reserved `artifacts/…` paths are matched before your own Routes.
+
+---
+
+## Artifact storage & registry
+
+These cover the two distinct artifact surfaces: the **storage adapter** (the global, cross-thread browser backed by `storage.artifact`) and the **per-thread registry** (the artifacts a renderer registered while rendering the current thread). See [Artifacts](/docs/agent/core-concepts/artifacts) for how the two relate.
+
+### `useArtifactStorage`
+
+```ts
+function useArtifactStorage(): ArtifactStorage | null;
+```
+
+Returns the `ArtifactStorage` adapter you configured via `storage.artifact`, or `null` when no artifact storage is configured. Use it to `list`/`get`/`update` artifacts from your own components — most commonly to **save an edited artifact** from a renderer's `actual` view. Always guard for `null` before calling.
+
+```tsx
+function SaveButton({ id, content }: { id: string; content: unknown }) {
+ const storage = useArtifactStorage();
+ if (!storage) return null;
+ return storage.update({ id, content })}>Save ;
+}
+```
+
+For the full `ArtifactStorage` shape see [Adapters & formats](/docs/agent/reference/adapters-and-formats).
+
+### `useArtifactCategories`
+
+```ts
+function useArtifactCategories(): ArtifactCategory[];
+```
+
+Returns the `artifactCategories` you passed to `AgentInterface` (each `{ name, filter: { type: string[] } }`), or an empty array if you passed none. Use it to render your own category-aware UI — a custom artifact nav, a filtered picker, grouping logic — instead of relying on the built-in `ArtifactNav`.
+
+```tsx
+function CategoryTabs() {
+ const categories = useArtifactCategories();
+ return categories.map((c) => {c.name} );
+}
+```
+
+### `useArtifactList`
+
+```ts
+function useArtifactList(filter?: { type?: string[] }): Record<
+ string,
+ ArtifactEntry[]
+>;
+// ArtifactEntry = { id: string; version: number; heading: string; type: string }
+```
+
+Returns the **per-thread artifact registry** — the artifacts a renderer registered while rendering the *current* thread, keyed for grouping (the same data that powers the per-thread Workspace rail). Pass `filter.type` to limit to specific artifact types. This is the in-thread registry, **not** the global storage browser — it reflects what the open conversation produced and works even with ephemeral storage.
+
+```tsx
+function ThreadArtifacts() {
+ const groups = useArtifactList({ type: ["code_artifact"] });
+ return Object.values(groups)
+ .flat()
+ .map((a) => {a.heading} );
+}
+```
+
+### `useArtifactRenderer`
+
+```ts
+function useArtifactRenderer(toolName: string): ArtifactRendererConfig | null;
+```
+
+Looks up a registered artifact renderer by its `toolName`, or `null` if none is registered for that name. Use it when you need to reach a renderer's config directly — for example, to render an artifact's `preview`/`actual` in a custom surface outside the default flow.
+
+```tsx
+// `controls` is the same { isActive, isStreaming, open, close, toggle }
+// object the framework passes to a renderer's preview/actual.
+function CustomPreview({ toolName, props, controls }) {
+ const renderer = useArtifactRenderer(toolName);
+ if (!renderer) return null;
+ return renderer.preview(props, controls);
+}
+```
+
+The registry indexes renderers by both `toolName` and `type`; see [defineArtifactRenderer](/docs/agent/reference/define-artifact-renderer).
+
+### `useArtifactRendererRegistry`
+
+```ts
+function useArtifactRendererRegistry(): ArtifactRendererRegistry | null;
+// ArtifactRendererRegistry = {
+// byToolName: Map;
+// byType: Map;
+// }
+```
+
+An **advanced escape hatch** that returns the whole renderer registry, indexed both by `toolName` and by artifact `type`, or `null` when none is configured. Normal lookups should use `useArtifactRenderer(toolName)` above — reach for the registry only when you need custom dispatching (for example, resolving a renderer by `type` rather than by `toolName`). The standalone helpers `lookupArtifactRenderer` and `lookupArtifactRendererByType` resolve a single config against the same registry.
+
+```tsx
+function RendererByType({ type }: { type: string }) {
+ const registry = useArtifactRendererRegistry();
+ const renderer = registry?.byType.get(type);
+ if (!renderer) return null;
+ return {renderer.toolName} ;
+}
+```
+
+---
+
+## Threads & messages
+
+### `useThread`
+
+```ts
+function useThread(selector: (state: ThreadState) => T): T;
+```
+
+A selector hook scoped to the **current thread** inside ``. Pass a function that picks the slice you need; the component only re-renders when that slice changes. The thread state exposes the running state — most notably `isRunning` (a reply is in flight) — along with a way to cancel the in-flight message. Select narrowly.
+
+```tsx
+function StopButton() {
+ const isRunning = useThread((s) => s.isRunning);
+ const cancelMessage = useThread((s) => s.cancelMessage);
+ if (!isRunning) return null;
+ return Stop ;
+}
+```
+
+See [Conversations](/docs/agent/core-concepts/conversations) for the run lifecycle, `isRunning`, and cancellation.
+
+### `useThreadList`
+
+```ts
+function useThreadList(selector: (state: ThreadListState) => T): T;
+```
+
+A selector hook for the **list of threads** (the data behind `AgentInterface.ThreadList`) — loaded threads, pending state, and the actions for creating, selecting, and deleting threads. Like `useThread`, pass a selector so you only re-render on the slice you read. Use it to build a custom thread switcher or sidebar.
+
+```tsx
+function ThreadCount() {
+ const count = useThreadList((s) => s.threads.length);
+ return {count} chats ;
+}
+```
+
+### `useMessage`
+
+```ts
+function useMessage(): Message;
+```
+
+Returns the canonical `Message` for the **current message row** — the same hook the built-in message renderer uses, so you never thread the message through props. Because it returns the live message, your component re-renders as the reply streams in (`content` grows token by token). **Throws if called outside a message component** — it only works inside the components you pass via the `components` prop or inside `AgentInterface.Messages`.
+
+```tsx
+function AssistantMessage() {
+ const message = useMessage();
+ return {message.content}
;
+}
+```
+
+See [Message rendering](/docs/agent/customize/message-rendering) for custom message components.
+
+---
+
+## Detailed views
+
+The **detailed view** is the in-thread panel that opens when a user expands an artifact (the renderer's `actual` rendered in a side panel rather than inline). These hooks drive that system — they're for advanced custom surfaces; most apps never touch them directly because the renderer `controls` (`open`/`close`/`toggle`) and the Workspace rail handle the common cases.
+
+### `useActiveDetailedView`
+
+```ts
+function useActiveDetailedView(): /* the currently open detailed view, or null */;
+```
+
+Returns the detailed view that's currently open, or a null/empty value when none is open. Use it to react to whichever view is active — e.g. a header that reflects the open artifact.
+
+```tsx
+const active = useActiveDetailedView();
+```
+
+### `useDetailedView`
+
+```ts
+function useDetailedView(viewId: string): /* the detailed view for that id */;
+```
+
+Looks up a specific detailed view by its id. Use it when you hold a known view id and need its state.
+
+```tsx
+const view = useDetailedView(viewId);
+```
+
+### `useDetailedViewStore`
+
+```ts
+function useDetailedViewStore(): /* the detailed-view store */;
+```
+
+Returns the underlying store backing the detailed-view system — the registry of views plus the actions to open and close them. Use it to drive the panel programmatically from a custom surface.
+
+```tsx
+const store = useDetailedViewStore();
+```
+
+### `useDetailedViewPortalTarget`
+
+```ts
+function useDetailedViewPortalTarget(): /* the portal mount target */;
+```
+
+Returns the DOM target the detailed-view panel portals into. Use it when you render custom panel content that must mount into the same target as the built-in detailed view.
+
+```tsx
+const target = useDetailedViewPortalTarget();
+```
+
+### `useThreadContextStore`
+
+```ts
+function useThreadContextStore(): /* the thread-context store */;
+```
+
+Returns the store holding the **thread context** — the per-thread registry of artifacts a renderer registered (via its `parser` returning non-null `meta`). This is the source the Workspace rail and `useArtifactList` read from. Use it directly only when you need lower-level access than `useArtifactList` provides.
+
+```tsx
+const ctx = useThreadContextStore();
+```
+
+The exact return shapes of the detailed-view hooks and `useThreadContextStore` are internal store/view objects. For the common cases — opening the full view, listing a thread's artifacts — prefer the renderer `controls` ([defineArtifactRenderer](/docs/agent/reference/define-artifact-renderer)) and `useArtifactList` over reaching into these stores.
+
+---
+
+## Where each hook can be called
+
+| Hook | Call site | Outside `` |
+|------|-----------|----------------------------|
+| `useNav` | anywhere in the tree | throws |
+| `useArtifactStorage` | anywhere in the tree | returns `null` |
+| `useArtifactCategories` | anywhere in the tree | empty array |
+| `useArtifactList` | inside a thread | empty / per-thread |
+| `useArtifactRenderer` | anywhere in the tree | `null` |
+| `useArtifactRendererRegistry` | anywhere in the tree | `null` |
+| `useThread` | inside a thread | n/a |
+| `useThreadList` | anywhere in the tree | n/a |
+| `useMessage` | inside a message component only | throws |
+| `useActiveDetailedView` · `useDetailedView` · `useDetailedViewStore` · `useDetailedViewPortalTarget` | inside a thread | n/a |
+| `useThreadContextStore` | inside a thread | n/a |
+
+
+
+ Every prop AgentInterface accepts.
+
+
+ The storage and LLM adapter shapes hooks read from.
+
+
+ Register renderers and their preview/actual controls.
+
+
+ The composable subcomponents of AgentInterface.
+
+
diff --git a/docs/content/docs/agent/reference/self-hosting.mdx b/docs/content/docs/agent/reference/self-hosting.mdx
new file mode 100644
index 000000000..469143c10
--- /dev/null
+++ b/docs/content/docs/agent/reference/self-hosting.mdx
@@ -0,0 +1,509 @@
+---
+title: Self-hosting
+description: Run your own backend instead of OpenUI Cloud — connect your LLM with a streaming route, persist threads and artifacts against your own storage, on any server framework.
+---
+
+OpenUI Cloud handles the backend for you: the provider call, streaming, conversation history, and artifact storage all run behind a single managed endpoint, and `` just points at it (see [OpenUI Cloud](/docs/agent/getting-started/openui-cloud)). This page is for the **self-hosted** variant — you run the backend yourself, against your own provider keys, database, and server framework.
+
+Everything `` needs from a backend flows through two independent interfaces:
+
+- **`llm`** — a `ChatLLM` that produces replies. The browser-side [`fetchLLM`](/docs/agent/reference/adapters-and-formats) factory builds one for you.
+- **`storage`** — a `ChatStorage` that persists threads (and optionally artifacts). The [`restStorage`](/docs/agent/reference/adapters-and-formats) factory builds one for you.
+
+They are configured separately. You can run a real `llm` with ephemeral (in-memory) storage, or persistent storage with a placeholder `llm`. For the full type signatures of every interface and factory named here, see [Adapters & formats](/docs/agent/reference/adapters-and-formats); this page is the worked-example home for wiring them against your own backend.
+
+The provider key **always** lives server-side. `fetchLLM` only ever talks to your own origin (e.g. `/api/chat`), never directly to `api.openai.com`. The key is read from `process.env` inside your route and is never bundled into the browser.
+
+## 1. Connect your LLM
+
+The `llm` channel is two halves of one loop:
+
+- **Browser half** — `fetchLLM({ url, streamAdapter, messageFormat })` POSTs `{ threadId, messages }` to your route and parses the streamed reply.
+- **Server half** — a route handler that receives `{ threadId, messages }`, calls your provider with streaming on, and returns the stream.
+
+```tsx
+import { AgentInterface, fetchLLM, openAIReadableStreamAdapter, openAIMessageFormat } from "@openuidev/react-ui";
+
+const llm = fetchLLM({
+ url: "/api/chat", // your route handler
+ streamAdapter: openAIReadableStreamAdapter(), // how to parse the streamed response
+ messageFormat: openAIMessageFormat, // how to shape outgoing messages
+});
+
+export default function App() {
+ return ;
+}
+```
+
+Your route always receives a JSON body of `{ threadId, messages }` and returns a streaming `Response`. `fetchLLM` runs that response through `streamAdapter` to turn the bytes into the AG-UI events the UI renders.
+
+`threadId` is in the body so your route can scope context, log, or attach per-thread state. The minimal route can ignore it.
+
+### Choosing `streamAdapter` and `messageFormat`
+
+`streamAdapter` governs the **response** (how the streamed bytes are decoded); `messageFormat` governs the **request** (how outgoing `messages` are shaped). They are chosen independently but pair up by provider. Each adapter is a factory — **call it**: `openAIAdapter()`, not the bare reference.
+
+| Provider / route output | `streamAdapter` | `messageFormat` |
+|---|---|---|
+| OpenAI Chat Completions, NDJSON from `stream.toReadableStream()` | `openAIReadableStreamAdapter()` | `openAIMessageFormat` |
+| OpenAI Chat Completions, raw SSE (`data: {…}\n\n`, `data: [DONE]`) | `openAIAdapter()` | `openAIMessageFormat` |
+| OpenAI **Responses / Conversations** API stream | `openAIResponsesAdapter()` | `openAIConversationMessageFormat` |
+| LangGraph stream (named SSE events) | `langGraphAdapter()` | `langGraphMessageFormat` |
+| A backend that already emits AG-UI events | `agUIAdapter()` | depends on what your route expects |
+
+The full catalogue and the precise wire format each adapter expects are in [Adapters & formats](/docs/agent/reference/adapters-and-formats).
+
+### Recipe: OpenAI
+
+With `openAIMessageFormat` on the client, `messages` already arrives in OpenAI's chat shape, so you forward it straight to the SDK. `stream.toReadableStream()` emits NDJSON, which `openAIReadableStreamAdapter()` parses on the client.
+
+```ts
+// app/api/chat/route.ts
+import OpenAI from "openai";
+
+const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // server-side only
+
+export async function POST(req: Request) {
+ const { threadId, messages } = await req.json();
+
+ const stream = await openai.chat.completions.create({
+ model: "gpt-4o",
+ stream: true, // ← the part that makes it stream
+ messages, // already OpenAI-shaped via openAIMessageFormat
+ });
+
+ // openAIReadableStreamAdapter() parses exactly this NDJSON output.
+ return new Response(stream.toReadableStream(), {
+ headers: { "Content-Type": "text/event-stream" },
+ });
+}
+```
+
+**Variant — forward raw SSE.** If you proxy another OpenAI-compatible service that emits SSE, return the SSE bytes unchanged and switch the client to `openAIAdapter()`:
+
+```ts
+export async function POST(req: Request) {
+ const { messages } = await req.json();
+ const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
+ },
+ body: JSON.stringify({ model: "gpt-4o", stream: true, messages }),
+ });
+ return new Response(upstream.body, { headers: { "Content-Type": "text/event-stream" } });
+}
+```
+
+### Recipe: Anthropic (translate to AG-UI)
+
+Anthropic streams its own format, and OpenUI has **no built-in adapter for it**. Rather than invent one, the route translates Anthropic's stream into **AG-UI events** as it goes, and the client uses `agUIAdapter()`. AG-UI events are emitted as Server-Sent Events: each event is a line `data: {json}\n\n`, and the JSON's `type` field names the event. This is the general pattern for any provider OpenUI lacks a native adapter for.
+
+The events a text reply needs:
+
+| Event `type` | Fields | Meaning |
+|---|---|---|
+| `TEXT_MESSAGE_START` | `messageId` | An assistant text message begins. |
+| `TEXT_MESSAGE_CONTENT` | `messageId`, `delta` | A chunk of assistant text. |
+| `TEXT_MESSAGE_END` | `messageId` | The text message is complete. |
+| `RUN_ERROR` | `message` | The turn failed; surfaces as an error in the UI. |
+
+```ts
+// app/api/chat/route.ts
+import Anthropic from "@anthropic-ai/sdk";
+
+const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); // server-side only
+
+export async function POST(req: Request) {
+ const { messages } = await req.json(); // { threadId, messages } from fetchLLM
+
+ // Anthropic takes `system` as a top-level field, not a message role.
+ const system = messages
+ .filter((m: any) => m.role === "system")
+ .map((m: any) => m.content)
+ .join("\n");
+ const turns = messages.filter((m: any) => m.role !== "system");
+
+ const stream = new ReadableStream({
+ async start(controller) {
+ const sse = (event: object) =>
+ controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`));
+ const messageId = crypto.randomUUID();
+
+ try {
+ const anthropicStream = anthropic.messages.stream({
+ model: "claude-3-5-sonnet-latest",
+ max_tokens: 1024,
+ system: system || undefined,
+ messages: turns,
+ });
+
+ sse({ type: "TEXT_MESSAGE_START", messageId });
+ for await (const chunk of anthropicStream) {
+ if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
+ sse({ type: "TEXT_MESSAGE_CONTENT", messageId, delta: chunk.delta.text });
+ }
+ }
+ sse({ type: "TEXT_MESSAGE_END", messageId });
+ } catch (err) {
+ sse({ type: "RUN_ERROR", message: err instanceof Error ? err.message : "Anthropic call failed" });
+ } finally {
+ controller.close(); // flips the UI loading state off — leave it open and the loader never disappears
+ }
+ },
+ });
+
+ return new Response(stream, {
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
+ });
+}
+```
+
+Paired client — `agUIAdapter()` because the route emits AG-UI events; `openAIMessageFormat` shapes the outgoing `role` / `content` turns:
+
+```tsx
+import { agUIAdapter, openAIMessageFormat } from "@openuidev/react-ui";
+
+const llm = fetchLLM({
+ url: "/api/chat",
+ streamAdapter: agUIAdapter(),
+ messageFormat: openAIMessageFormat,
+});
+```
+
+To let the model call tools, declare provider-format tools on the request, run the ones it asks for, and emit the matching `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` / `TOOL_CALL_RESULT` events between the text events, looping back to the provider with each result until it returns a turn with no tool calls. (`TOOL_CALL_RESULT.content` is always a string.) See [Tools](/docs/agent/core-concepts/tools).
+
+## 2. Stream — your route must stream
+
+Streaming is a property of your **backend route**, not of ``. The frontend reads whatever the route sends; if the route buffers the full answer and returns it in one shot, the UI has nothing to stream. Two rules:
+
+1. **Enable the provider's streaming mode** (`stream: true` for OpenAI, `messages.stream(...)` for Anthropic) and return the streamed body — never an `await`-ed full completion sent as JSON.
+2. **Close the stream when generation finishes.** The client's `isRunning` flips back to `false` only when the stream **closes**. A stream left open hangs the loader forever.
+
+If responses arrive all-at-once, the cause is almost always one of two things: the route isn't actually streaming, or `streamAdapter` doesn't match the route's wire format (an adapter that can't decode the bytes can't render them incrementally). Check the network tab: if the response body trickles in gradually, the route streams and the problem is the adapter; if it completes in one shot after a pause, the problem is the route.
+
+### Honor the abort signal
+
+Every run is backed by an `AbortController`. `fetchLLM` threads the UI's `AbortSignal` into its `fetch`, so when the user hits stop, the HTTP request to your route is aborted. To stop the **upstream provider call** too, pass that request's signal into the provider SDK. Without it, the UI stops consuming the stream but the provider may keep generating server-side. A cancelled run is treated as intentional — it does not surface a thread error.
+
+## 3. Persist conversations
+
+Add a `storage` adapter and the default sidebar's thread list, "New chat" button, thread switching, and deletion all operate against your backend — conversations survive reloads. The fastest path is `restStorage`, which maps each `ChatStorage` operation to one HTTP call under a `baseUrl`:
+
+```tsx
+import { AgentInterface, fetchLLM, restStorage, agUIAdapter } from "@openuidev/react-ui";
+
+const llm = fetchLLM({ url: "/api/chat", streamAdapter: agUIAdapter() });
+const storage = restStorage({ baseUrl: "/api/threads" });
+
+export default function App() {
+ return ;
+}
+```
+
+That's the whole frontend change — one prop. You implement the five endpoints `restStorage` calls; the sidebar's thread lifecycle comes for free. With `baseUrl: "/api/threads"`:
+
+| Operation | Method | Path | Request body | Returns |
+|---|---|---|---|---|
+| List threads | `GET` | `/api/threads/get` | — (append `?cursor={cursor}` to paginate) | `{ threads, nextCursor? }` |
+| Create thread | `POST` | `/api/threads/create` | `{ messages: [...] }` (first user message via the message format) | the new `Thread` |
+| Get messages | `GET` | `/api/threads/get/{threadId}` | — | the thread's `Message[]` |
+| Update thread | `PATCH` | `/api/threads/update/{threadId}` | the full `Thread` | the updated `Thread` |
+| Delete thread | `DELETE` | `/api/threads/delete/{threadId}` | — | nothing |
+
+`restStorage` throws a descriptive error on any non-`ok` response. The `Thread` shape at the boundary is `{ id, title, createdAt: string | number, isPending? }`. By default `restStorage` uses the identity message format; pass `messageFormat` (and optional `headers` / `fetch`) if your backend stores a provider-specific shape — it is applied to the `create` body (`toApi`) and the `get/{threadId}` response (`fromApi`).
+
+```tsx
+import { openAIMessageFormat } from "@openuidev/react-ui";
+
+const storage = restStorage({
+ baseUrl: "/api/threads",
+ messageFormat: openAIMessageFormat,
+ headers: { "x-tenant": "acme" }, // sent on every request
+});
+```
+
+A minimal Next.js App Router implementation, lining up exactly with the table:
+
+```ts
+// app/api/threads/get/route.ts — list threads (and paginate)
+export async function GET(req: NextRequest) {
+ const cursor = req.nextUrl.searchParams.get("cursor") ?? undefined;
+ const { threads, nextCursor } = await db.listThreads({ cursor, limit: 20 });
+ return NextResponse.json({ threads, nextCursor });
+}
+
+// app/api/threads/create/route.ts — create from the first message
+export async function POST(req: NextRequest) {
+ const { messages } = await req.json();
+ const thread = await db.createThread({
+ title: deriveTitle(messages[0]), // e.g. first ~40 chars of user text
+ createdAt: Date.now(),
+ messages,
+ });
+ return NextResponse.json(thread); // the new Thread
+}
+
+// app/api/threads/get/[threadId]/route.ts — load one thread's messages
+export async function GET(_req: NextRequest, { params }: { params: { threadId: string } }) {
+ return NextResponse.json(await db.getMessages(params.threadId)); // Message[]
+}
+
+// app/api/threads/update/[threadId]/route.ts — update (e.g. rename)
+export async function PATCH(req: NextRequest, { params }: { params: { threadId: string } }) {
+ const thread = await req.json(); // the full Thread
+ return NextResponse.json(await db.updateThread(params.threadId, thread));
+}
+
+// app/api/threads/delete/[threadId]/route.ts — delete
+export async function DELETE(_req: NextRequest, { params }: { params: { threadId: string } }) {
+ await db.deleteThread(params.threadId);
+ return new NextResponse(null, { status: 204 });
+}
+```
+
+`db` is a stand-in for your persistence layer — Postgres, SQLite, Redis, a cloud KV store, anything. The endpoints are thin: read/write threads and messages, return the shapes the table describes.
+
+### Custom `ChatStorage` instead
+
+If the REST endpoint shape doesn't fit your backend — GraphQL, a client-side store like IndexedDB, a SaaS SDK, or just a different URL layout — implement `ChatStorage` directly. It's an object with a `thread` member satisfying `ThreadStorage` (five methods) plus an optional `artifact` member. `restStorage` is itself just a `ChatStorage` built this way for the common REST case.
+
+```ts
+import type { ChatStorage } from "@openuidev/react-ui";
+import { gql } from "@/lib/graphql";
+
+export const storage: ChatStorage = {
+ thread: {
+ async listThreads(cursor) {
+ const { threads, nextCursor } = await gql(LIST_THREADS, { cursor });
+ return { threads, nextCursor };
+ },
+ async createThread(firstMessage) {
+ const { thread } = await gql(CREATE_THREAD, { firstMessage });
+ return thread; // a Thread: { id, title, createdAt }
+ },
+ async getMessages(threadId) {
+ const { messages } = await gql(GET_MESSAGES, { threadId });
+ return messages; // Message[]
+ },
+ async updateThread(thread) {
+ const { updated } = await gql(UPDATE_THREAD, { thread });
+ return updated; // the updated Thread
+ },
+ async deleteThread(id) {
+ await gql(DELETE_THREAD, { id });
+ },
+ },
+};
+```
+
+The five methods map one-to-one onto the sidebar:
+
+| Method | When it runs |
+|---|---|
+| `listThreads(cursor?)` | Populating the thread list; loading more on scroll. |
+| `createThread(firstMessage)` | User sends the first message of a new chat. Receives the `UserMessage`. |
+| `getMessages(threadId)` | User opens a thread. Returns its `Message[]`. |
+| `updateThread(thread)` | A thread changes (e.g. rename). Returns the updated `Thread`. |
+| `deleteThread(id)` | User deletes a thread. |
+
+## 4. Store artifacts
+
+Adding an optional `artifact` channel to your storage makes every dashboard, report, and presentation the agent produces a durable, searchable, cross-thread record — and `` renders the entire artifact browser (sidebar entry → searchable list → full-page view) for you. `ArtifactStorage` is three methods:
+
+```ts
+interface ArtifactStorage {
+ // name/type filtering is SERVER-SIDE; cursor-paginated
+ list(params?: { name?: string; type?: string[]; cursor?: string; limit?: number }):
+ Promise<{ artifacts: ArtifactSummary[]; nextCursor?: string }>;
+ get(id: string): Promise;
+ update(patch: { id: string; content: unknown }): Promise; // for editable artifacts
+}
+```
+
+```ts
+import type { ChatStorage, ArtifactStorage } from "@openuidev/react-ui";
+
+const artifact: ArtifactStorage = {
+ async list({ name, type, cursor, limit } = {}) {
+ const params = new URLSearchParams();
+ if (name) params.set("name", name);
+ if (type) type.forEach((t) => params.append("type", t));
+ if (cursor) params.set("cursor", cursor);
+ if (limit) params.set("limit", String(limit));
+ const res = await fetch(`/api/artifacts?${params}`);
+ if (!res.ok) throw new Error(`list artifacts failed: ${res.status}`);
+ return res.json(); // { artifacts: ArtifactSummary[], nextCursor?: string }
+ },
+ async get(id) {
+ const res = await fetch(`/api/artifacts/${id}`);
+ if (!res.ok) throw new Error(`get artifact failed: ${res.status}`);
+ return res.json(); // Artifact (includes content)
+ },
+ async update({ id, content }) {
+ const res = await fetch(`/api/artifacts/${id}`, {
+ method: "PATCH",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ content }),
+ });
+ if (!res.ok) throw new Error(`update artifact failed: ${res.status}`);
+ return res.json(); // ArtifactSummary
+ },
+};
+
+const storage: ChatStorage = {
+ thread: restStorage({ baseUrl: "/api/threads" }).thread, // reuse the REST thread channel
+ artifact,
+};
+```
+
+Four things to keep right (full contract in [Adapters & formats](/docs/agent/reference/adapters-and-formats)):
+
+- **`list` filters on the server.** `name` (partial-match search on `title`) and `type` go to your backend, not applied client-side — the browser's search box and category tabs become these params, paginated via `cursor` / `nextCursor`.
+- **`get` returns the full record** including `content`, which the renderer needs to draw the full view. On the storage path a renderer's `parser` is called as `parser({ args: undefined, response: artifact.content }, { isStreaming: false })`, so make your parser tolerate `args` being `undefined` and read the artifact's data from `response`.
+- **`update` is for editable artifacts.** A renderer reaches storage via the `useArtifactStorage()` hook (returns `ArtifactStorage | null` — guard for `null`) and calls `update({ id, content })` to persist.
+- **`threadId` is required on every `ArtifactSummary`.** It powers the "go to thread" jump from the artifact view back to the conversation that produced it.
+
+The matching Next.js backend — filtering and pagination live on the server:
+
+```ts
+// app/api/artifacts/route.ts — list (search + type filter + paginate)
+export async function GET(req: NextRequest) {
+ const sp = req.nextUrl.searchParams;
+ const name = sp.get("name") ?? undefined; // search box
+ const type = sp.getAll("type"); // category filter (repeatable)
+ const cursor = sp.get("cursor") ?? undefined; // pagination
+ const limit = Number(sp.get("limit") ?? 20);
+ const { artifacts, nextCursor } = await db.listArtifacts({
+ name, type: type.length ? type : undefined, cursor, limit,
+ });
+ // each artifact MUST include threadId
+ return NextResponse.json({ artifacts, nextCursor });
+}
+
+// app/api/artifacts/[id]/route.ts — get (full content) + update
+export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
+ return NextResponse.json(await db.getArtifact(params.id)); // Artifact (with content)
+}
+export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
+ const { content } = await req.json();
+ return NextResponse.json(await db.updateArtifact(params.id, content)); // ArtifactSummary
+}
+```
+
+The artifact `type` you store must match the `type` a registered renderer declares — it's the contract linking a stored artifact to the renderer that draws it. How artifacts get *written* (a tool call, a background job, a direct write) is up to your agent; the browser only reads, gets, and updates. For organizing the browser into groups and the per-thread Workspace rail (which does **not** require storage), see [Artifacts](/docs/agent/core-concepts/artifacts) and [`` props](/docs/agent/reference/agentinterface-props).
+
+## 5. Framework-agnostic (plain Web `Request`/`Response`)
+
+None of the above is Next.js-specific. The only thing `ChatLLM.send` must return is a standard **Web `Response`** with a streaming body — the primitive that Next.js route handlers, Hono, Bun, Deno, and Cloudflare Workers all speak natively, and that Express can be adapted to in a few lines. Write the handler once, mount it anywhere.
+
+```ts
+// chat-handler.ts — pure Web platform, no framework imports
+import OpenAI from "openai";
+
+const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // server-side only
+
+export async function handleChat(req: Request): Promise {
+ const { threadId, messages } = await req.json();
+ const stream = await openai.chat.completions.create({
+ model: "gpt-4o",
+ stream: true,
+ messages,
+ });
+ return new Response(stream.toReadableStream(), {
+ headers: { "Content-Type": "text/event-stream" },
+ });
+}
+```
+
+Mounting it differs only at the edges:
+
+```ts
+// Next.js App Router — app/api/chat/route.ts
+import { handleChat } from "@/chat-handler";
+export const POST = (req: Request) => handleChat(req);
+```
+
+```ts
+// Hono
+import { Hono } from "hono";
+import { handleChat } from "./chat-handler";
+const app = new Hono();
+app.post("/api/chat", (c) => handleChat(c.req.raw)); // c.req.raw is a Web Request
+export default app;
+```
+
+```ts
+// Bun / Deno / Cloudflare Workers — the runtime hands you a Request directly
+export default {
+ fetch(req: Request) {
+ const url = new URL(req.url);
+ if (req.method === "POST" && url.pathname === "/api/chat") return handleChat(req);
+ return new Response("Not found", { status: 404 });
+ },
+};
+```
+
+```ts
+// Express — bridge req/res to Web Request/Response
+import express from "express";
+import { handleChat } from "./chat-handler";
+const app = express();
+app.post("/api/chat", express.json(), async (req, res) => {
+ const webReq = new Request("http://local/api/chat", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(req.body),
+ });
+ const webRes = await handleChat(webReq);
+ res.status(webRes.status);
+ webRes.headers.forEach((v, k) => res.setHeader(k, v));
+ const reader = webRes.body!.getReader();
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
+ res.write(chunk.value);
+ }
+ res.end();
+});
+```
+
+The handler is identical across all four — the framework only routes the request to it.
+
+On the client, a fully custom `llm` is an object with `send` (returns a streaming `Response`, **forwards the `signal`**) and `streamProtocol` (the adapter matching your body's wire format):
+
+```tsx
+import { type ChatLLM, type ChatStorage, openAIReadableStreamAdapter } from "@openuidev/react-ui";
+
+const llm: ChatLLM = {
+ async send({ threadId, messages, signal }) {
+ return fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ threadId, messages }),
+ signal, // forward it so the stop button actually aborts the request
+ });
+ },
+ streamProtocol: openAIReadableStreamAdapter(),
+};
+```
+
+Because `send` is just a function returning a `Response`, you can layer logic around the request — a retry on 503, a fresh auth token per attempt, telemetry — without a proxy. It still runs in the browser, so it must call your **own** origin, never a provider API directly. The factory path and the custom path are the same two interfaces; they mix freely (e.g. `restStorage` for threads plus a custom `llm`), and `` can't tell the difference.
+
+## What you have now
+
+A self-hosted ``: a model reply that streams (key server-side), durable threads and artifacts against your own storage, on any server framework — because the contract is two small interfaces over plain Web `Request`/`Response`.
+
+
+
+ The full `ChatLLM`, `ChatStorage`, `ThreadStorage`, `ArtifactStorage` interfaces and the `fetchLLM` / `restStorage` factories, plus every stream adapter and message format.
+
+
+ The `llm`, `storage`, `artifactRenderers`, and `artifactCategories` props in full.
+
+
+ How threads, messages, and streaming fit together.
+
+
+ The artifact browser, Workspace rail, and how stored artifacts render.
+
+
diff --git a/docs/content/docs/api-reference/cli.mdx b/docs/content/docs/api-reference/cli.mdx
index 25d9c6f9b..86a6c1c92 100644
--- a/docs/content/docs/api-reference/cli.mdx
+++ b/docs/content/docs/api-reference/cli.mdx
@@ -167,9 +167,6 @@ openui generate ./src/library.ts --out src/generated/system-prompt.txt
## See also
-
- Scaffold and run a new OpenUI chat app with `openui create` in under 5 minutes.
-
`createLibrary`, `PromptOptions`, and the `Library` interface that `openui generate` reads.
diff --git a/docs/content/docs/api-reference/index.mdx b/docs/content/docs/api-reference/index.mdx
index 7f16e3d4c..1819f2e7d 100644
--- a/docs/content/docs/api-reference/index.mdx
+++ b/docs/content/docs/api-reference/index.mdx
@@ -11,7 +11,7 @@ The OpenUI SDK is split into packages that build on each other:
- **`@openuidev/react-headless`** — Headless chat state management. Provides `ChatProvider`, thread/message hooks, streaming protocol adapters (OpenAI, AG-UI), and message format converters. Use this when you want full control over your chat UI.
-- **`@openuidev/react-ui`** — Prebuilt chat layouts (`Copilot`, `FullScreen`, `BottomTray`) and two ready-to-use component libraries (general-purpose and chat-optimized). Depends on both packages above. Use this for the fastest path to a working chat interface.
+- **`@openuidev/react-ui`** — `AgentInterface`, a ready-to-use artifact chat surface with thread history, plus two built-in component libraries (general-purpose and chat-optimized). Depends on both packages above. Use this for the fastest path to a working chat interface.
- **`@openuidev/react-email`** — Pre-built email component library and prompt options for model-generated emails that can be rendered to HTML with React Email.
@@ -55,8 +55,8 @@ The OpenUI SDK is split into packages that build on each other:
format converters.
- Copilot, FullScreen, BottomTray chat layouts, and two built-in component libraries
- (general-purpose and chat-optimized).
+ AgentInterface, a ready-to-use artifact chat surface with thread history, and two built-in
+ component libraries (general-purpose and chat-optimized).
API reference for the pre-built email templates library and prompt options.
diff --git a/docs/content/docs/api-reference/react-ui.mdx b/docs/content/docs/api-reference/react-ui.mdx
index 86a960d7f..3fcbdc5e3 100644
--- a/docs/content/docs/api-reference/react-ui.mdx
+++ b/docs/content/docs/api-reference/react-ui.mdx
@@ -1,66 +1,54 @@
---
title: "@openuidev/react-ui"
-description: API reference for prebuilt chat layouts and default component library exports.
+description: API reference for the AgentInterface chat surface and default component library exports.
---
-Use this package for prebuilt chat UIs and default component library primitives.
+Use this package for the prebuilt `AgentInterface` chat surface and default component library primitives.
## Import
```ts
-import { Copilot, FullScreen, BottomTray } from "@openuidev/react-ui";
+import { AgentInterface } from "@openuidev/react-ui";
```
-## Layout components
+## Chat interface
-These layouts are documented in Chat UI guides and are all wrapped with `ChatProvider`.
+`AgentInterface` is the package's chat surface — a full-page artifact chat with a thread-history sidebar, composer, and per-thread artifact workspace. It wraps `ChatProvider`, so it accepts the provider's `storage` + `llm` props alongside the shared UI props below.
-### `Copilot`
+### `AgentInterface`
-Sidebar chat layout.
-
-```ts
-type CopilotProps = ChatLayoutProps;
-```
-
-### `FullScreen`
-
-Full-page chat layout with thread sidebar.
-
-```ts
-type FullScreenProps = ChatLayoutProps;
-```
-
-### `BottomTray`
-
-Floating/collapsible tray layout.
+```tsx
+import { AgentInterface, openAIAdapter, type ChatLLM } from "@openuidev/react-ui";
+import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
-```ts
-type BottomTrayProps = ChatLayoutProps & {
- isOpen?: boolean;
- onOpenChange?: (isOpen: boolean) => void;
- defaultOpen?: boolean;
+const llm: ChatLLM = {
+ send: ({ messages, signal }) =>
+ fetch("/api/chat", { method: "POST", body: JSON.stringify({ messages }), signal }),
+ streamProtocol: openAIAdapter(),
};
+
+ ;
```
-## Shared layout props (`ChatLayoutProps`)
+## Props (`AgentInterfaceProps`)
-All three layouts accept:
+`AgentInterface` extends `ChatProviderProps` (minus `children`) and adds the shared UI + theme props:
-- Chat provider props: `apiUrl`/`processMessage`, thread APIs, `streamProtocol`, `messageFormat`
+- Chat provider props (from `@openuidev/react-headless`):
+ - `storage?: ChatStorage` — thread (and optional artifact) persistence; defaults to in-memory
+ - `llm: ChatLLM` — required; `{ send({ threadId, messages, signal }), streamProtocol }`
+ - `artifactRenderers?: ArtifactRendererConfig[]`
+ - `artifactCategories?: ArtifactCategory[]`
- Shared UI props:
+ - `componentLibrary?: Library` (from `@openuidev/react-lang`) — drives auto-GenUI rendering
+ - `components?: AgentInterfaceComponents` — `{ AssistantMessage?, UserMessage? }` overrides
- `logoUrl?: string`
- `agentName?: string`
- - `messageLoading?: React.ComponentType`
+ - `labels?: AgentInterfaceLabels`
+ - `starters?: ConversationStarterProps[]`
+ - `starterVariant?: ConversationStarterVariant`
- `scrollVariant?: ScrollVariant`
- - `isArtifactActive?: boolean`
- - `renderArtifact?: () => React.ReactNode`
- - `welcomeMessage?: WelcomeMessageConfig`
- - `conversationStarters?: ConversationStartersConfig`
- - `assistantMessage?: AssistantMessageComponent`
- - `userMessage?: UserMessageComponent`
- - `composer?: ComposerComponent`
- - `componentLibrary?: Library` (from `@openuidev/react-lang`)
+ - `scrollOnLoad?: boolean`
- Theme wrapper props:
- `theme?: ThemeProps`
- `disableThemeProvider?: boolean`
@@ -117,7 +105,7 @@ import {
} from "@openuidev/react-ui/genui-lib";
```
-**`openuiChatLibrary`** — Root is `Card` (vertical, no layout params). Includes chat-specific components: `FollowUpBlock`, `ListBlock`, `SectionBlock`. Does not include `Stack`. Use with `FullScreen` / `BottomTray` / `Copilot` chat interfaces.
+**`openuiChatLibrary`** — Root is `Card` (vertical, no layout params). Includes chat-specific components: `FollowUpBlock`, `ListBlock`, `SectionBlock`. Does not include `Stack`. Use with the `AgentInterface` chat surface.
**`openuiLibrary`** — Root is `Stack`. Full layout suite with `Stack`, `Tabs`, `Carousel`, `Accordion`, `Modal`, etc. Use with the standalone `Renderer` or any non-chat layout (e.g., playground, embedded widgets, dashboards).
@@ -131,7 +119,7 @@ npx @openuidev/cli@latest generate ./src/library.ts --out src/generated/system-p
```tsx
// Chat interface — system prompt stays on the server
-
+
// Standalone renderer
diff --git a/docs/content/docs/chat/api-contract.mdx b/docs/content/docs/chat/api-contract.mdx
deleted file mode 100644
index bcb4ff8eb..000000000
--- a/docs/content/docs/chat/api-contract.mdx
+++ /dev/null
@@ -1,108 +0,0 @@
----
-title: The API Contract
-description: JSON contract for threads, messages, and streaming.
----
-
-OpenUI Chat can work with any backend stack as long as the API contract is respected.
-
-This page is the reference source for request and response shapes. Use [Connecting to LLM](/docs/chat/connecting) for decision guidance and [Connect Thread History](/docs/chat/persistence) for the setup flow.
-
-## Chat endpoint contract
-
-When you pass `apiUrl`, OpenUI sends a `POST` request with this shape:
-
-```json
-{
- "threadId": "thread_123",
- "messages": [{ "id": "msg_1", "role": "user", "content": "Hello" }]
-}
-```
-
-- `threadId` is the selected thread ID when persistence is enabled, or `"ephemeral"` when no thread storage is configured.
-- `messages` is converted through `messageFormat.toApi(messages)` before the request is sent.
-
-If your backend already accepts the default AG-UI message shape, each message can stay in this form:
-
-```json
-{ "id": "msg_1", "role": "user", "content": "Hello" }
-```
-
-### Stream response
-
-Your response stream must match one of these cases:
-
-| Backend response shape | Frontend config |
-| :--------------------------------------- | :----------------------------------------------- |
-| OpenUI Protocol | No `streamProtocol` needed |
-| Raw OpenAI Chat Completions SSE | `streamProtocol={openAIAdapter()}` |
-| OpenAI SDK `toReadableStream()` / NDJSON | `streamProtocol={openAIReadableStreamAdapter()}` |
-| OpenAI Responses API | `streamProtocol={openAIResponsesAdapter()}` |
-
-## Default thread API contract
-
-When using `threadApiUrl="/api/threads"`, OpenUI expects the base URL plus these default path segments:
-
-| Action | Method | URL | Request body | Response |
-| :------------ | :------- | :------------------------ | :------------- | :---------------------------------------- |
-| List threads | `GET` | `/api/threads/get` | — | `{ threads: Thread[], nextCursor?: any }` |
-| Create thread | `POST` | `/api/threads/create` | `{ messages }` | `Thread` |
-| Update thread | `PATCH` | `/api/threads/update/:id` | `Thread` | `Thread` |
-| Delete thread | `DELETE` | `/api/threads/delete/:id` | — | empty response is fine |
-| Load messages | `GET` | `/api/threads/get/:id` | — | message array in your backend format |
-
-`messages` in the create request is the first user message, already converted through `messageFormat.toApi([firstMessage])`.
-
-## Thread shape
-
-```ts
-type Thread = {
- id: string;
- title: string;
- createdAt: string | number;
-};
-```
-
-## Message format contract
-
-`messageFormat` controls both directions:
-
-- `toApi()` shapes the `messages` array sent to `apiUrl` and `threadApiUrl/create`
-- `fromApi()` shapes the array returned from `threadApiUrl/get/:id`
-
-OpenUI ships with these built-in message converters:
-
-| Converter | Use when your backend expects or returns... |
-| :-------------------------------- | :------------------------------------------ |
-| Default | AG-UI message objects |
-| `openAIMessageFormat` | OpenAI chat completion messages |
-| `openAIConversationMessageFormat` | OpenAI Responses conversation items |
-
-Every persisted message should include a unique `id`. Without stable message IDs, history hydration and message updates become unreliable.
-
-## Example custom converter
-
-```ts
-const myCustomFormat = {
- toApi(messages) {
- return messages.map((message) => ({
- speaker: message.role,
- text: message.content,
- }));
- },
- fromApi(items) {
- return items.map((item) => ({
- id: item.id,
- role: item.speaker,
- content: item.text,
- }));
- },
-};
-```
-
-{/* add visual: flow-chart showing how messageFormat.toApi affects outgoing chat and thread-create requests, and how messageFormat.fromApi affects thread loading */}
-
-## Related guides
-
-- [Next.js Implementation](/docs/chat/nextjs)
-- [Connect Thread History](/docs/chat/persistence)
-- [Providers](/docs/chat/providers)
diff --git a/docs/content/docs/chat/artifacts.mdx b/docs/content/docs/chat/artifacts.mdx
deleted file mode 100644
index 0114d01f9..000000000
--- a/docs/content/docs/chat/artifacts.mdx
+++ /dev/null
@@ -1,173 +0,0 @@
----
-title: Artifacts
-description: Add side-panel content that opens from inline previews in chat.
----
-
-Artifacts let a component render a compact inline preview inside the chat message and expand into a full side panel when clicked. Use them for code viewers, document previews, embedded frames, or any content that benefits from a larger canvas.
-
-```tsx
-import { defineComponent } from "@openuidev/react-lang";
-import { Artifact } from "@openuidev/react-ui";
-import { z } from "zod";
-
-const ArtifactCodeBlock = defineComponent({
- name: "ArtifactCodeBlock",
- props: z.object({
- language: z.string(),
- title: z.string(),
- codeString: z.string(),
- }),
- description: "Code block that opens in the artifact side panel",
- component: Artifact({
- title: (props) => props.title,
- preview: (props, { open, isActive }) => (
-
- ),
- panel: (props) => (
- {props.codeString}
- ),
- }),
-});
-```
-
-## How it works
-
-An artifact component has two parts:
-
-- **Preview** — a compact element rendered inline in the chat message. It receives an `open` callback to activate the side panel.
-- **Panel** — the full content rendered inside `ArtifactPanel`, portaled into the `ArtifactPortalTarget` in your layout. Only one panel is visible at a time.
-
-`Artifact()` is a factory function that wires these together. It generates a `ComponentRenderer` that handles ID generation, artifact state, and panel portaling internally. Pass the result as the `component` field of `defineComponent`.
-
-## `Artifact()` config
-
-```ts
-import { Artifact } from "@openuidev/react-ui";
-
-Artifact({
- title, // string | (props) => string
- preview, // (props, controls) => ReactNode
- panel, // (props, controls) => ReactNode
- panelProps, // optional — className, errorFallback, header
-});
-```
-
-| Option | Type | Description |
-| ------------ | ----------------------------------------------------- | -------------------------------------------------------- |
-| `title` | `string \| (props: P) => string` | Panel header title. Static string or derived from props. |
-| `preview` | `(props: P, controls: ArtifactControls) => ReactNode` | Inline preview rendered in the chat message. |
-| `panel` | `(props: P, controls: ArtifactControls) => ReactNode` | Content rendered inside the side panel. |
-| `panelProps` | `{ className?, errorFallback?, header? }` | Optional overrides forwarded to `ArtifactPanel`. |
-
-Both `preview` and `panel` receive the full Zod-inferred props as the first argument and `ArtifactControls` as the second.
-
-## `ArtifactControls`
-
-The controls object passed to `preview` and `panel` render functions.
-
-```ts
-interface ArtifactControls {
- isActive: boolean; // whether this artifact's panel is currently open
- open: () => void; // activate this artifact
- close: () => void; // deactivate this artifact
- toggle: () => void; // toggle open/close
-}
-```
-
-The preview typically uses `open` and `isActive` to show a click-to-expand button. The panel can use `close` to render a dismiss button inside the panel body.
-
-## Layout setup
-
-Built-in layouts (`FullScreen`, `Copilot`, `BottomTray`) mount `ArtifactPortalTarget` automatically. Artifact panels render into this target with no extra setup.
-
-If you build a custom layout with the headless hooks, mount one `ArtifactPortalTarget` in your layout where the panel should appear.
-
-```tsx
-import { ArtifactPortalTarget } from "@openuidev/react-ui";
-
-function Layout() {
- return (
-
- );
-}
-```
-
-Only one `ArtifactPortalTarget` should be mounted at a time. All artifact panels portal into this single element.
-
-## Headless hooks
-
-For custom layouts or advanced control, use the artifact hooks from `@openuidev/react-headless`.
-
-### `useArtifact(id)`
-
-Binds a component to a specific artifact by ID. Returns activation state and actions.
-
-```ts
-import { useArtifact } from "@openuidev/react-headless";
-
-const { isActive, open, close, toggle } = useArtifact(artifactId);
-```
-
-### `useActiveArtifact()`
-
-Returns global artifact state — whether any artifact is open, and a close action. Use this in layout components that resize or show overlays when any artifact is active.
-
-```ts
-import { useActiveArtifact } from "@openuidev/react-headless";
-
-const { isArtifactActive, activeArtifactId, closeArtifact } = useActiveArtifact();
-```
-
-Both hooks require a `ChatProvider` ancestor in the component tree.
-
-## Manual wiring
-
-If `Artifact()` does not fit your use case, wire the pieces directly. This is the escape hatch for full control.
-
-```tsx
-import { defineComponent } from "@openuidev/react-lang";
-import { ArtifactPanel } from "@openuidev/react-ui";
-import { useArtifact } from "@openuidev/react-headless";
-import { useId } from "react";
-
-const CustomArtifact = defineComponent({
- name: "CustomArtifact",
- props: CustomSchema,
- description: "Artifact with full manual control",
- component: ({ props }) => {
- const artifactId = useId();
- const { isActive, open, close } = useArtifact(artifactId);
-
- return (
- <>
- {isActive ? "Viewing" : "Open"}
-
- {/* panel content */}
-
- >
- );
- },
-});
-```
-
-`ArtifactPanel` accepts `artifactId`, `title`, `children`, `className`, `errorFallback`, and `header` (boolean or custom ReactNode). It renders nothing when the artifact is inactive.
-
-## Related guides
-
-
-
- Create custom openui-lang components with `defineComponent`.
-
-
- Build a fully custom chat UI with headless hooks.
-
-
- Full reference for all headless hooks.
-
-
- Adjust colors, mode, and theme overrides.
-
-
diff --git a/docs/content/docs/chat/bottom-tray.mdx b/docs/content/docs/chat/bottom-tray.mdx
deleted file mode 100644
index 7f5e3419d..000000000
--- a/docs/content/docs/chat/bottom-tray.mdx
+++ /dev/null
@@ -1,50 +0,0 @@
----
-title: BottomTray
-description: A floating support-style chat widget.
----
-
-`BottomTray` provides a floating chat widget instead of a full-page chat surface.
-
-This page covers the widget-style layout for support flows, product assistants, and experiences where chat stays collapsed until a user opens it.
-
-```tsx
-import { BottomTray } from "@openuidev/react-ui";
-
-export function App() {
- return (
- <>
- {/* Your app */}
-
- >
- );
-}
-```
-
-
-
-## Controlled open state
-
-```tsx
-
-```
-
-Use the same backend configuration props as the other layouts. The only layout-specific props are the open-state controls.
-
-That means you can start with `BottomTray` for the UI and still reuse the same `apiUrl`, `processMessage`, `streamProtocol`, and `threadApiUrl` setup from the other layouts.
-
-## Related guides
-
-
-
- Configure endpoint, adapters, and auth headers.
-
-
- Load saved threads and previous messages into the widget.
-
-
- Configure the empty-state content and starter prompts.
-
-
- Adjust mode and theme overrides.
-
-
diff --git a/docs/content/docs/chat/connecting.mdx b/docs/content/docs/chat/connecting.mdx
deleted file mode 100644
index 25eb95f35..000000000
--- a/docs/content/docs/chat/connecting.mdx
+++ /dev/null
@@ -1,130 +0,0 @@
----
-title: Connecting to LLM
-description: Configure apiUrl, streamProtocol adapters, and authentication.
----
-
-Every chat layout needs a backend connection, but there are a few separate pieces involved:
-
-- how the frontend sends the request
-- how the backend streams the response
-- what message shape the backend expects
-
-This page introduces each one first, then shows how to choose the right combination for your backend.
-
-## `apiUrl`
-
-`apiUrl` is the simplest connection option. Use it when your frontend can call one backend endpoint directly and you do not need custom request logic on the client.
-
-```tsx
-import { FullScreen } from "@openuidev/react-ui";
-
- ;
-```
-
-With `apiUrl`, OpenUI sends the message history to your endpoint for you. If your backend expects a different message format, configure `messageFormat`. If you need custom headers, extra fields, or a different request body, use `processMessage` instead.
-
-## `processMessage`
-
-`processMessage` gives you full control over the request. Use it when you need to:
-
-- add auth headers
-- build a dynamic URL
-- include extra request fields
-- convert `messages` before sending them
-
-```tsx
-import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-import { openuiLibrary } from "@openuidev/react-ui/genui-lib";
-
- {
- return fetch("/api/chat", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${getToken()}`,
- },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIReadableStreamAdapter()}
- componentLibrary={openuiLibrary}
- agentName="Assistant"
-/>;
-```
-
-`processMessage` receives `threadId`, `messages`, and `abortController`, and must return a standard `Response` from your backend call.
-
-## `streamProtocol`
-
-`streamProtocol` tells OpenUI how to parse the response stream. By default, OpenUI expects the OpenUI Protocol, so only set this when your backend streams a different format.
-
-| Backend output | Frontend config |
-| :--------------------------------------- | :----------------------------------------------- |
-| OpenUI Protocol | No adapter required |
-| Raw OpenAI Chat Completions SSE | `streamProtocol={openAIAdapter()}` |
-| OpenAI SDK `toReadableStream()` / NDJSON | `streamProtocol={openAIReadableStreamAdapter()}` |
-| OpenAI Responses API | `streamProtocol={openAIResponsesAdapter()}` |
-
-```tsx
-import { openAIReadableStreamAdapter } from "@openuidev/react-headless";
-
- ;
-```
-
-## `messageFormat`
-
-`messageFormat` controls the shape of the `messages` array sent to your backend and the shape expected when loading thread history.
-
-| Backend message shape | Frontend config |
-| :---------------------------------- | :------------------------------------------------ |
-| AG-UI message shape | No converter required |
-| OpenAI chat completions messages | `messageFormat={openAIMessageFormat}` |
-| OpenAI Responses conversation items | `messageFormat={openAIConversationMessageFormat}` |
-
-```tsx
-import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-
- ;
-```
-
-Use `messageFormat` whenever your backend expects or returns a non-default message shape. This is especially important if you store messages for thread history.
-
-## How to choose
-
-Once you know what each prop does, the decision becomes:
-
-1. Start with `apiUrl`.
-2. Switch to `processMessage` only if you need auth, extra fields, dynamic URLs, or request conversion.
-3. Add `streamProtocol` only if your backend does not stream the default OpenUI Protocol.
-4. Add `messageFormat` only if your backend expects or returns a non-default message shape.
-
-{/* add visual: flow-chart showing the decision between apiUrl and processMessage, then mapping backend stream output to the correct streamProtocol adapter and messageFormat choice */}
-
-## Rules summary
-
-- `apiUrl` is the simplest path when one endpoint can handle the request as-is.
-- `processMessage` is the right choice when you need auth, extra fields, or payload conversion.
-- `streamProtocol` parses the response stream.
-- `messageFormat` converts request messages and loaded thread history.
-
-## Related guides
-
-- [Next.js Implementation](/docs/chat/nextjs)
-- [The API Contract](/docs/chat/api-contract)
-- [Providers](/docs/chat/providers)
-- [Connect Thread History](/docs/chat/persistence)
diff --git a/docs/content/docs/chat/copilot.mdx b/docs/content/docs/chat/copilot.mdx
deleted file mode 100644
index 2b4608d66..000000000
--- a/docs/content/docs/chat/copilot.mdx
+++ /dev/null
@@ -1,58 +0,0 @@
----
-title: Copilot
-description: The sidebar assistant layout for in-app chat experiences.
----
-
-`Copilot` provides a sidebar assistant layout that stays visible alongside the rest of your application.
-
-This layout keeps the main app screen in view while chat stays available at the side. For a full-page chat surface, see [FullScreen](/docs/chat/fullscreen). For a floating widget, see [BottomTray](/docs/chat/bottom-tray).
-
-```tsx
-import { Copilot } from "@openuidev/react-ui";
-
-export function App() {
- return (
-
- {/* Your app */}
-
-
- );
-}
-```
-
-
-
-## Common configuration
-
-```tsx
-
-```
-
-`Copilot` only handles the UI layer. It is a good fit for support panels, assistant sidebars, and workflows where users need to keep the main screen visible while chatting.
-
-Set up your backend connection in [Connecting to LLM](/docs/chat/connecting), connect thread history in [Connect Thread History](/docs/chat/persistence), and customize the empty state in [Welcome & Starters](/docs/chat/welcome).
-
-## Related guides
-
-
-
- Configure `apiUrl`, adapters, and auth.
-
-
- Load thread lists and previous messages from your backend.
-
-
- Configure the empty-state experience.
-
-
- Adjust colors, mode, and theme overrides.
-
-
- Override assistant, user, and composer UI.
-
-
diff --git a/docs/content/docs/chat/custom-chat-components.mdx b/docs/content/docs/chat/custom-chat-components.mdx
deleted file mode 100644
index 91e5638fb..000000000
--- a/docs/content/docs/chat/custom-chat-components.mdx
+++ /dev/null
@@ -1,71 +0,0 @@
----
-title: Custom Chat Components
-description: Override the composer, assistant messages, and user messages.
----
-
-You can customize specific UI surfaces without rebuilding the full chat stack:
-
-- `composer`
-- `assistantMessage`
-- `userMessage`
-
-These props replace the built-in UI entirely for that surface. If you override them, your component becomes responsible for rendering the message or composer state correctly.
-
-Use these props when you want to swap a specific surface while keeping the built-in layout and state model. If you need to redesign the whole chat shell, use the headless APIs instead.
-
-## Custom composer
-
-```tsx
-function MyComposer({ onSend, onCancel, isRunning }) {
- // your UI
-}
-
- ;
-```
-
-### `ComposerProps`
-
-```ts
-type ComposerProps = {
- onSend: (message: string) => void;
- onCancel: () => void;
- isRunning: boolean;
- isLoadingMessages: boolean;
-};
-```
-
-Call `onSend(text)` when the user submits. Use `onCancel()` to stop a running response.
-
-Even a simple custom composer should still account for both `isRunning` and `isLoadingMessages`, because the composer may need to disable input while streaming or while history is still loading.
-
-## Custom assistant and user messages
-
-```tsx
-function AssistantBubble({ message }) {
- return {message.content}
;
-}
-
-function UserBubble({ message }) {
- return {String(message.content)}
;
-}
-
- ;
-```
-
-The `message` prop is the full `AssistantMessage` or `UserMessage` object from `@openuidev/react-headless`.
-
-## Important behavior notes
-
-- `assistantMessage` replaces the default assistant wrapper, including the avatar/container UI.
-- `userMessage` replaces the default user bubble wrapper.
-- If you pass `componentLibrary` and also pass `assistantMessage`, your custom component takes priority. That means you are responsible for rendering any structured assistant content yourself.
-- `composer` should handle both `isRunning` and `isLoadingMessages` so the input behaves correctly while streaming or loading history.
-- If your custom assistant renderer only handles plain text, document that constraint in your app and avoid assuming `message.content` is always a simple string.
-
-{/* add visual: image showing the default assistant bubble beside a custom assistant bubble implementation */}
-
-## Related guides
-
-- [Headless Intro](/docs/chat/headless-intro)
-- [Custom UI Guide](/docs/chat/custom-ui-guide)
-- [GenUI](/docs/chat/genui)
diff --git a/docs/content/docs/chat/custom-ui-guide.mdx b/docs/content/docs/chat/custom-ui-guide.mdx
deleted file mode 100644
index 2932964f3..000000000
--- a/docs/content/docs/chat/custom-ui-guide.mdx
+++ /dev/null
@@ -1,139 +0,0 @@
----
-title: Custom UI Guide
-description: Build a chat interface from scratch using headless hooks.
----
-
-This guide shows a complete headless composition with:
-
-1. `ChatProvider` for backend configuration
-2. `useThreadList()` for the sidebar
-3. `useThread()` for messages and the composer
-
-The goal is to show how those pieces fit together in one working example, not to prescribe a specific visual design.
-
-```tsx
-import { useState } from "react";
-import {
- ChatProvider,
- openAIMessageFormat,
- openAIReadableStreamAdapter,
- useThread,
- useThreadList,
-} from "@openuidev/react-headless";
-
-function ThreadSidebar() {
- const { threads, selectedThreadId, isLoadingThreads, selectThread, switchToNewThread } =
- useThreadList();
-
- return (
-
- New chat
- {isLoadingThreads ? Loading threads...
: null}
- {threads.map((thread) => (
- selectThread(thread.id)}
- aria-pressed={thread.id === selectedThreadId}
- >
- {thread.title}
-
- ))}
-
- );
-}
-
-function MessageList() {
- const { messages, isRunning } = useThread();
-
- return (
-
- {messages.map((message) => (
-
- {message.role}: {String(message.content ?? "")}
-
- ))}
- {isRunning ?
Thinking...
: null}
-
- );
-}
-
-function Composer() {
- const { processMessage, cancelMessage, isRunning } = useThread();
- const [input, setInput] = useState("");
-
- return (
-
- );
-}
-
-function CustomChat() {
- return (
-
-
-
-
-
-
-
- );
-}
-
-export default function App() {
- return (
- {
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- threadApiUrl="/api/threads"
- streamProtocol={openAIReadableStreamAdapter()}
- messageFormat={openAIMessageFormat}
- >
-
-
- );
-}
-```
-
-This example uses the same backend assumptions as the built-in layouts:
-
-- `openAIMessageFormat.toApi(messages)` is called explicitly in `processMessage` to convert messages to OpenAI format — the `messageFormat` prop does not transform messages for `processMessage`
-- `messageFormat={openAIMessageFormat}` is still needed here because `threadApiUrl` is set — it tells the UI how to convert messages when loading saved thread history
-- `openAIReadableStreamAdapter()` matches `response.toReadableStream()`
-- `threadApiUrl` enables saved thread history
-
-If you want Generative UI in a headless build, you also need to render structured assistant content yourself instead of relying on the built-in `componentLibrary` behavior from the layout components.
-
-{/* add visual: flow-chart showing ChatProvider feeding ThreadSidebar, MessageList, and Composer through useThreadList and useThread */}
-
-## Related guides
-
-- [Headless Intro](/docs/chat/headless-intro)
-- [Hooks & State](/docs/chat/hooks)
-- [Connecting to LLM](/docs/chat/connecting)
diff --git a/docs/content/docs/chat/from-scratch.mdx b/docs/content/docs/chat/from-scratch.mdx
deleted file mode 100644
index a26e15f1d..000000000
--- a/docs/content/docs/chat/from-scratch.mdx
+++ /dev/null
@@ -1,196 +0,0 @@
----
-title: End-to-End Guide
-description: Build a complete OpenUI Chat setup from an existing app.
----
-
-This guide shows a complete OpenUI Chat setup in an existing Next.js App Router project.
-
-This path covers:
-
-- a built-in chat layout
-- an OpenAI-backed route handler
-- frontend request wiring with `processMessage`
-- the correct stream adapter and message format
-- optional thread history
-- optional headless customization
-
-{/* add visual: flow-chart showing frontend page -> processMessage -> /api/chat route -> OpenAI -> toReadableStream() -> openAIReadableStreamAdapter() -> rendered UI with componentLibrary */}
-
-## Prerequisites
-
-Complete [Installation](/docs/chat/installation) first, then return here to wire the chat flow.
-
-## 1. Generate the system prompt
-
-If you want Generative UI, generate a system prompt from the component library. The backend loads this prompt and sends it to the model with each request.
-
-If you only want plain text chat, you can skip this step and omit `componentLibrary` in the next examples.
-
-```bash
-npx @openuidev/cli@latest generate ./src/library.ts --out src/generated/system-prompt.txt
-```
-
-Where `src/library.ts` exports your library:
-
-```ts
-export {
- openuiLibrary as library,
- openuiPromptOptions as promptOptions,
-} from "@openuidev/react-ui/genui-lib";
-```
-
-Add this as a prebuild step in `package.json`:
-
-```json
-"scripts": {
- "generate:prompt": "openui generate src/library.ts --out src/generated/system-prompt.txt",
- "dev": "pnpm generate:prompt && next dev",
- "build": "pnpm generate:prompt && next build"
-}
-```
-
-This prompt tells the model which UI components it is allowed to emit.
-
-## 2. Create the streaming backend route
-
-Create `app/api/chat/route.ts`:
-
-```ts
-import { readFileSync } from "fs";
-import { join } from "path";
-import { NextRequest } from "next/server";
-import OpenAI from "openai";
-
-const client = new OpenAI();
-const systemPrompt = readFileSync(join(process.cwd(), "src/generated/system-prompt.txt"), "utf-8");
-
-export async function POST(req: NextRequest) {
- try {
- const { messages } = await req.json();
-
- const response = await client.chat.completions.create({
- model: "gpt-5.2",
- messages: [{ role: "system", content: systemPrompt }, ...messages],
- stream: true,
- });
-
- return new Response(response.toReadableStream(), {
- headers: {
- "Content-Type": "text/event-stream",
- "Cache-Control": "no-cache, no-transform",
- Connection: "keep-alive",
- },
- });
- } catch (err) {
- console.error(err);
- const message = err instanceof Error ? err.message : "Unknown error";
- return new Response(JSON.stringify({ error: message }), {
- status: 500,
- headers: { "Content-Type": "application/json" },
- });
- }
-}
-```
-
-The system prompt is loaded from the file generated by the CLI. The route only receives messages from the frontend — the prompt never leaves the server.
-
-## 3. Render a layout and connect it to the route
-
-`FullScreen` is a good baseline because it includes both the thread list and the main chat surface.
-
-This guide uses `processMessage` instead of `apiUrl` so the request body stays explicit.
-
-```tsx
-import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-import { openuiLibrary } from "@openuidev/react-ui/genui-lib";
-
-export default function Page() {
- return (
-
- {
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIReadableStreamAdapter()}
- componentLibrary={openuiLibrary}
- agentName="Assistant"
- />
-
- );
-}
-```
-
-Why this setup matters:
-
-- `processMessage` gives you control over the request body
-- `openAIMessageFormat.toApi(messages)` converts messages to OpenAI format before sending
-- `openAIReadableStreamAdapter()` matches `response.toReadableStream()`
-- `componentLibrary={openuiLibrary}` lets the UI render structured responses
-
-### Checkpoint
-
-At this point, you should be able to send a message and receive streamed responses in the UI.
-
-Guides: [Connecting to LLM](/docs/chat/connecting), [Next.js Implementation](/docs/chat/nextjs), [Providers](/docs/chat/providers)
-
-## 4. Connect Thread History (optional)
-
-Stop here if you only need a working streamed chat UI.
-
-Continue with this section only if your app also needs saved threads and message history from the backend.
-
-If you want the UI to load saved threads and previous messages, add `threadApiUrl` and implement the default thread contract described in [Connect Thread History](/docs/chat/persistence).
-
-```tsx
- {
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- threadApiUrl="/api/threads"
- streamProtocol={openAIReadableStreamAdapter()}
- messageFormat={openAIMessageFormat}
- componentLibrary={openuiLibrary}
- agentName="Assistant"
-/>
-```
-
-When using `processMessage`, you must call `openAIMessageFormat.toApi(messages)` explicitly in the request body — the `messageFormat` prop does not transform messages for `processMessage`. The `messageFormat={openAIMessageFormat}` prop here is for `threadApiUrl`: it tells the UI how to convert messages when loading saved thread history from the backend.
-
-## 5. Switch layouts or go headless (optional)
-
-This step does not change your backend contract. It only changes the UI layer that sits on top of the same chat and thread wiring.
-
-Once the backend contract is working, you can keep the same chat wiring and swap the UI layer.
-
-- Use [Copilot](/docs/chat/copilot) for a sidebar layout
-- Use [BottomTray](/docs/chat/bottom-tray) for a floating widget
-- Use [Headless Intro](/docs/chat/headless-intro) and [Custom UI Guide](/docs/chat/custom-ui-guide) for full UI control
-
-## You now have
-
-- a streaming `/api/chat` route
-- a connected chat layout
-- the correct OpenAI message conversion and stream adapter
-- optional GenUI support
-- a clear path to thread history and headless customization
-
-## Next steps
-
-- [Connect Thread History](/docs/chat/persistence)
-- [GenUI](/docs/chat/genui)
-- [Custom UI Guide](/docs/chat/custom-ui-guide)
diff --git a/docs/content/docs/chat/fullscreen.mdx b/docs/content/docs/chat/fullscreen.mdx
deleted file mode 100644
index 7798557c8..000000000
--- a/docs/content/docs/chat/fullscreen.mdx
+++ /dev/null
@@ -1,55 +0,0 @@
----
-title: FullScreen
-description: The full-page, ChatGPT-style chat layout.
----
-
-`FullScreen` provides a full-page chat layout with the built-in thread list and main conversation area.
-
-This page covers the complete built-in layout. For a sidebar inside an existing app screen, see [Copilot](/docs/chat/copilot). For a floating widget, see [BottomTray](/docs/chat/bottom-tray).
-
-```tsx
-import { FullScreen } from "@openuidev/react-ui";
-
-export function App() {
- return (
-
-
-
- );
-}
-```
-
-
-
-## Common configuration
-
-```tsx
-
-```
-
-`FullScreen` is the best starting point for end-to-end setup because it exercises both the message surface and thread UI. See the [End-to-End Guide](/docs/chat/from-scratch) if you want to wire the whole flow manually.
-
-## Related guides
-
-
-
- Configure endpoint, streaming adapters, and auth.
-
-
- Load thread lists and message history from your backend.
-
-
- Customize the empty-state experience.
-
-
- Control colors, mode, and theme overrides.
-
-
- Override the built-in composer and message rendering.
-
-
diff --git a/docs/content/docs/chat/genui.mdx b/docs/content/docs/chat/genui.mdx
deleted file mode 100644
index 861590cca..000000000
--- a/docs/content/docs/chat/genui.mdx
+++ /dev/null
@@ -1,123 +0,0 @@
----
-title: GenUI
-description: Use Generative UI with Chat components.
----
-
-GenUI lets assistant messages render structured UI instead of plain text. To make it work, you need both sides of the setup:
-
-- `componentLibrary` on the frontend so OpenUI knows how to render components
-- a generated system prompt on the backend so the model knows what it is allowed to emit
-
-Passing `componentLibrary` alone is not enough.
-
-The frontend and backend have different jobs here:
-
-- the frontend renders structured responses through `componentLibrary`
-- the backend loads the generated system prompt and sends it to the model with each request
-
-If either side is missing, the model falls back to plain text or emits components the UI cannot render.
-
-Generate the system prompt with the CLI:
-
-```bash
-npx @openuidev/cli@latest generate ./src/library.ts --out src/generated/system-prompt.txt
-```
-
-The CLI auto-detects exported `PromptOptions` alongside your library, so examples and rules are included automatically. See [System Prompts](/docs/openui-lang/system-prompts) for details.
-
-## Use the chat library
-
-`openuiChatLibrary` is optimised for conversational chat: every response is wrapped in a `Card`, and it includes chat-specific components like `FollowUpBlock`, `ListBlock`, and `SectionBlock`.
-
-```tsx
-import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
-
-export default function Page() {
- return (
- {
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIAdapter()}
- componentLibrary={openuiChatLibrary}
- agentName="Assistant"
- />
- );
-}
-```
-
-In this setup:
-
-- The system prompt is generated at build time via the CLI and loaded by the backend
-- `openAIMessageFormat.toApi(messages)` converts messages before sending
-- `componentLibrary={openuiChatLibrary}` tells the UI how to render the model output
-- `openAIAdapter()` parses raw SSE chunks from the backend
-
-This is the minimal complete pattern for GenUI in a chat interface. For a non-chat renderer or custom layout, use `openuiLibrary` and `openuiPromptOptions` from the same import path.
-
-
-
-
Plain text response
- 
-
-
-
GenUI response
- 
-
-
-
-## Use your own library
-
-If you need domain-specific components, keep the same request flow and swap in your own library definition:
-
-First, generate the system prompt from your custom library:
-
-```bash
-npx @openuidev/cli@latest generate ./src/lib/my-library.ts --out src/generated/system-prompt.txt
-```
-
-Then wire up the frontend — it only needs the component library for rendering:
-
-```tsx
-import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-import { myLibrary } from "@/lib/my-library";
-
- {
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIReadableStreamAdapter()}
- componentLibrary={myLibrary}
- agentName="Assistant"
-/>;
-```
-
-Your custom library needs two things:
-
-- a `createLibrary()` result, so the CLI can generate the system prompt and the frontend can render components
-- optional `PromptOptions` export for examples and rules (auto-detected by the CLI)
-
-Your backend loads the generated prompt file and sends it to the model alongside the message history.
-
-## Related guides
-
-- [End-to-End Guide](/docs/chat/from-scratch)
-- [Connecting to LLM](/docs/chat/connecting)
-- [Define Components](/docs/openui-lang/defining-components)
diff --git a/docs/content/docs/chat/headless-intro.mdx b/docs/content/docs/chat/headless-intro.mdx
deleted file mode 100644
index e1a54506a..000000000
--- a/docs/content/docs/chat/headless-intro.mdx
+++ /dev/null
@@ -1,51 +0,0 @@
----
-title: Headless Introduction
-description: Why and when to use headless mode with ChatProvider.
----
-
-This page introduces headless mode and the role of `ChatProvider` in a custom chat UI.
-
-The trade-off is simple: you get full control over rendering, but you become responsible for composing the sidebar, message list, and composer yourself.
-
-At the center is `ChatProvider`, which manages:
-
-- streaming state
-- thread list and selection
-- message sending/cancelation
-- thread-history hooks
-
-```tsx
-import { ChatProvider } from "@openuidev/react-headless";
-
-export function App() {
- return (
-
-
-
- );
-}
-```
-
-`ChatProvider` accepts the same backend props as the built-in layouts:
-
-- `apiUrl` or `processMessage`
-- `streamProtocol`
-- `messageFormat`
-- `threadApiUrl` or custom thread functions
-
-Thread history is not automatic. To load and save threads, you still need `threadApiUrl` or the custom thread handlers.
-
-The usual build order is:
-
-1. configure `ChatProvider` with your backend connection
-2. read state with `useThread()` and `useThreadList()`
-3. render your own sidebar, messages, and composer components
-
-{/* add visual: flow-chart showing ChatProvider at the center with hooks, backend config, and custom UI components around it */}
-
-## Related guides
-
-- [Hooks & State](/docs/chat/hooks)
-- [Custom UI Guide](/docs/chat/custom-ui-guide)
-- [Connecting to LLM](/docs/chat/connecting)
-- [Connect Thread History](/docs/chat/persistence)
diff --git a/docs/content/docs/chat/hooks.mdx b/docs/content/docs/chat/hooks.mdx
deleted file mode 100644
index 4b50fa8c6..000000000
--- a/docs/content/docs/chat/hooks.mdx
+++ /dev/null
@@ -1,166 +0,0 @@
----
-title: Hooks & State
-description: Deep dive into useThread, useThreadList, and related headless state hooks.
----
-
-All headless hooks must run inside `ChatProvider`.
-
-Use `useThread()` for the active conversation and `useThreadList()` for thread navigation. Most custom UIs need both.
-
-## Start with `ChatProvider`
-
-```tsx
-import {
- ChatProvider,
- openAIMessageFormat,
- openAIReadableStreamAdapter,
-} from "@openuidev/react-headless";
-
-export function App() {
- return (
-
-
-
- );
-}
-```
-
-That provider owns the shared state. The hooks below read from and write to that state.
-
-## `useThread()`
-
-Use `useThread()` for the currently selected conversation: messages, send state, loading state, and message mutations.
-
-```tsx
-const {
- messages,
- isRunning,
- isLoadingMessages,
- threadError,
- processMessage,
- cancelMessage,
- appendMessages,
- updateMessage,
- setMessages,
- deleteMessage,
-} = useThread();
-```
-
-### Common send flow
-
-```tsx
-function Composer() {
- const { processMessage, cancelMessage, isRunning } = useThread();
- const [input, setInput] = useState("");
-
- return (
-
- );
-}
-```
-
-Use `isLoadingMessages` to show a loading state when a saved thread is being hydrated, and use `threadError` to render request or load failures near the conversation surface.
-
-## `useThreadList()`
-
-Use `useThreadList()` for the sidebar: thread loading, selection, creation, pagination, and thread-level mutations.
-
-```tsx
-const {
- threads,
- isLoadingThreads,
- threadListError,
- selectedThreadId,
- hasMoreThreads,
- loadThreads,
- loadMoreThreads,
- switchToNewThread,
- createThread,
- selectThread,
- updateThread,
- deleteThread,
-} = useThreadList();
-```
-
-### Common sidebar flow
-
-```tsx
-function ThreadSidebar() {
- const {
- threads,
- selectedThreadId,
- hasMoreThreads,
- isLoadingThreads,
- loadMoreThreads,
- switchToNewThread,
- selectThread,
- deleteThread,
- } = useThreadList();
-
- return (
-
- New chat
-
- {threads.map((thread) => (
-
- selectThread(thread.id)}
- aria-pressed={thread.id === selectedThreadId}
- >
- {thread.title}
-
- deleteThread(thread.id)}>Delete
-
- ))}
-
- {hasMoreThreads ? (
- loadMoreThreads()} disabled={isLoadingThreads}>
- Load more
-
- ) : null}
-
- );
-}
-```
-
-`switchToNewThread()` clears the current selection so the next user message starts a new conversation. `updateThread()` is useful when you want to rename or otherwise patch thread metadata after creation.
-
-## Selectors
-
-Use selectors to minimize re-renders when you only need a small part of the store.
-
-```tsx
-const messages = useThread((state) => state.messages);
-const selectedThreadId = useThreadList((state) => state.selectedThreadId);
-```
-
-This is especially useful when your sidebar and message list are separate components and you do not want unrelated state updates to rerender both.
-
-{/* add visual: flow-chart showing how useThread maps to the active conversation and useThreadList maps to the thread sidebar */}
-
-## Related guides
-
-- [Headless Intro](/docs/chat/headless-intro)
-- [Custom UI Guide](/docs/chat/custom-ui-guide)
-- [Connect Thread History](/docs/chat/persistence)
diff --git a/docs/content/docs/chat/index.mdx b/docs/content/docs/chat/index.mdx
deleted file mode 100644
index 84b362efa..000000000
--- a/docs/content/docs/chat/index.mdx
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Chat
-description: Production-ready chat UI for AI agents. Drop-in layouts, streaming from any LLM provider, and Generative UI — all in a few lines of code.
----
diff --git a/docs/content/docs/chat/installation.mdx b/docs/content/docs/chat/installation.mdx
deleted file mode 100644
index 0b49eaa82..000000000
--- a/docs/content/docs/chat/installation.mdx
+++ /dev/null
@@ -1,93 +0,0 @@
----
-title: Installation
-description: Add OpenUI Chat to an existing Next.js App Router application.
----
-
-This page covers package installation, style imports, and a basic render check for an existing Next.js App Router app.
-
-
- **Starting a new project?** Skip this guide and use our scaffold command instead: `npx
- @openuidev/cli@latest create --name my-app`
-
-
-## Prerequisites
-
-This guide assumes:
-
-- Next.js App Router
-- React 18 or newer
-- a page where you can mount a chat layout
-
-## 1. Install dependencies
-
-Install the UI package, the headless core, and the icons package used by the built-in layouts.
-
-
-
- ```bash npm install @openuidev/react-ui @openuidev/react-headless lucide-react ```
-
-
- ```bash pnpm add @openuidev/react-ui @openuidev/react-headless lucide-react ```
-
-
- ```bash yarn add @openuidev/react-ui @openuidev/react-headless lucide-react ```
-
-
- ```bash bun add @openuidev/react-ui @openuidev/react-headless lucide-react ```
-
-
-
-## 2. Import the styles
-
-Import the component and theme styles in your root layout.
-
-```tsx
-import "@openuidev/react-ui/components.css";
-import "@openuidev/react-ui/styles/index.css";
-import "./globals.css";
-
-export default function RootLayout({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
-```
-
-These imports give you the default chat layout styling and theme tokens.
-
-## 3. Render a layout to verify setup
-
-Render one of the built-in layouts on a page to confirm the package is installed correctly.
-
-```tsx
-// app/page.tsx
-import { FullScreen } from "@openuidev/react-ui";
-
-export default function Page() {
- return (
-
-
-
- );
-}
-```
-
-At this stage, the page should render the layout shell. It will not send working chat requests until you add a backend.
-
-
-
-## Related guides
-
-
-
- Add the backend route, message conversion, stream adapter, and optional persistence.
-
-
- Compare the built-in layouts and choose the one you want to ship.
-
-
- Prefer a generated app instead of wiring everything manually.
-
-
diff --git a/docs/content/docs/chat/meta.json b/docs/content/docs/chat/meta.json
deleted file mode 100644
index 062aa6803..000000000
--- a/docs/content/docs/chat/meta.json
+++ /dev/null
@@ -1,31 +0,0 @@
-{
- "title": "Chat",
- "root": true,
- "pages": [
- "---Introduction---",
- "index",
- "quick-start",
- "installation",
- "genui",
- "from-scratch",
- "---Chat Layouts---",
- "copilot",
- "fullscreen",
- "bottom-tray",
- "artifacts",
- "---Configurations---",
- "connecting",
- "persistence",
- "welcome",
- "theming",
- "custom-chat-components",
- "---Headless (Advanced)---",
- "headless-intro",
- "hooks",
- "custom-ui-guide",
- "---Backend & Integrations---",
- "api-contract",
- "nextjs",
- "providers"
- ]
-}
diff --git a/docs/content/docs/chat/nextjs.mdx b/docs/content/docs/chat/nextjs.mdx
deleted file mode 100644
index 9912a52a7..000000000
--- a/docs/content/docs/chat/nextjs.mdx
+++ /dev/null
@@ -1,102 +0,0 @@
----
-title: Next.js Implementation
-description: Build a Route Handler for streaming chat responses.
----
-
-This page covers the Route Handler pattern and matching frontend configuration for a Next.js App Router setup.
-
-If you want the full install-and-render walkthrough, use the [End-to-End Guide](/docs/chat/from-scratch) instead.
-
-This page focuses on one specific backend pattern:
-
-- `processMessage` on the frontend to send messages
-- `openAIMessageFormat` to send OpenAI chat messages
-- `openAIReadableStreamAdapter()` because `response.toReadableStream()` emits NDJSON, not raw SSE
-- the system prompt stays on the server, generated at build time by the CLI
-
-## Route handler
-
-Generate the system prompt at build time:
-
-```bash
-npx @openuidev/cli@latest generate ./src/library.ts --out src/generated/system-prompt.txt
-```
-
-Create `app/api/chat/route.ts`:
-
-```ts
-import { readFileSync } from "fs";
-import { join } from "path";
-import { NextRequest } from "next/server";
-import OpenAI from "openai";
-
-const client = new OpenAI();
-const systemPrompt = readFileSync(join(process.cwd(), "src/generated/system-prompt.txt"), "utf-8");
-
-export async function POST(req: NextRequest) {
- try {
- const { messages } = await req.json();
-
- const response = await client.chat.completions.create({
- model: "gpt-5.2",
- messages: [{ role: "system", content: systemPrompt }, ...messages],
- stream: true,
- });
-
- return new Response(response.toReadableStream(), {
- headers: {
- "Content-Type": "text/event-stream",
- "Cache-Control": "no-cache, no-transform",
- Connection: "keep-alive",
- },
- });
- } catch (err) {
- console.error(err);
- const message = err instanceof Error ? err.message : "Unknown error";
- return new Response(JSON.stringify({ error: message }), {
- status: 500,
- headers: { "Content-Type": "application/json" },
- });
- }
-}
-```
-
-The system prompt is loaded from the file generated by the CLI. It never leaves the server.
-
-## Matching frontend configuration
-
-Because `toReadableStream()` produces newline-delimited JSON, pair it with `openAIReadableStreamAdapter()` on the frontend.
-
-When using `processMessage`, you must convert messages yourself with `openAIMessageFormat.toApi(messages)` before sending. The `messageFormat` prop only applies automatically for the `apiUrl` flow.
-
-```tsx
-import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-import { openuiLibrary } from "@openuidev/react-ui/genui-lib";
-
- {
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIReadableStreamAdapter()}
- componentLibrary={openuiLibrary}
- agentName="Assistant"
-/>;
-```
-
-Use `openAIAdapter()` only if your backend emits raw SSE chunks instead of the OpenAI SDK readable stream.
-
-{/* add visual: flow-chart showing request from FullScreen -> /api/chat route -> OpenAI chat completions -> toReadableStream() -> openAIReadableStreamAdapter() -> rendered assistant message */}
-
-## Related guides
-
-- [Connecting to LLM](/docs/chat/connecting)
-- [Providers](/docs/chat/providers)
-- [End-to-End Guide](/docs/chat/from-scratch)
diff --git a/docs/content/docs/chat/persistence.mdx b/docs/content/docs/chat/persistence.mdx
deleted file mode 100644
index 4135d7459..000000000
--- a/docs/content/docs/chat/persistence.mdx
+++ /dev/null
@@ -1,111 +0,0 @@
----
-title: Connect Thread History
-description: Configure threadApiUrl and load thread lists and message history.
----
-
-This page explains how to connect thread lists and previous messages from a backend.
-
-To connect thread history, either:
-
-- pass `threadApiUrl` and implement the default endpoint contract used by OpenUI
-- provide custom thread functions if your API shape is different
-
-This config only affects thread history. Your live chat request still comes from `apiUrl` or `processMessage`.
-
-## Default `threadApiUrl` contract
-
-When you pass `threadApiUrl="/api/threads"`, OpenUI appends its own path segments. The default requests look like this:
-
-| Action | Method | URL | Request body | Expected response |
-| :------------ | :------- | :------------------------ | :------------- | :---------------------------------------- |
-| List threads | `GET` | `/api/threads/get` | — | `{ threads: Thread[], nextCursor?: any }` |
-| Create thread | `POST` | `/api/threads/create` | `{ messages }` | `Thread` |
-| Update thread | `PATCH` | `/api/threads/update/:id` | `Thread` | `Thread` |
-| Delete thread | `DELETE` | `/api/threads/delete/:id` | — | empty response is fine |
-| Load messages | `GET` | `/api/threads/get/:id` | — | message array in your backend format |
-
-```tsx
-import { FullScreen } from "@openuidev/react-ui";
-
- ;
-```
-
-`createThread` sends the first user message as `messages`, already converted through your current `messageFormat`. `loadThread` expects the response body to be something `messageFormat.fromApi()` can read.
-
-## When to add `messageFormat`
-
-If your thread API stores messages in OpenUI's default shape, you do not need any extra config.
-
-If your thread API stores messages in OpenAI chat format, add `messageFormat={openAIMessageFormat}` so both chat requests and thread loading stay aligned.
-
-In other words:
-
-- `apiUrl` or `processMessage` handles sending new chat requests
-- `threadApiUrl` handles listing threads and loading saved messages
-- `messageFormat` keeps both paths aligned when your backend does not use the default AG-UI message shape
-
-```tsx
-import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-
- ;
-```
-
-## Use custom thread functions when your API differs
-
-If your backend already uses a different shape, such as:
-
-- REST routes like `/api/threads/:id/messages`
-- GraphQL
-- auth-protected endpoints with custom headers
-- a different request body for creating threads
-
-then provide the individual thread functions instead of relying on the default `threadApiUrl` behavior.
-
-```tsx
- {
- const res = await fetch(`/api/conversations?cursor=${cursor ?? ""}`);
- return res.json();
- }}
- createThread={async (firstMessage) => {
- const res = await fetch("/api/conversations", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ firstMessage }),
- });
- return res.json();
- }}
- updateThread={async (thread) => {
- const res = await fetch(`/api/conversations/${thread.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(thread),
- });
- return res.json();
- }}
- deleteThread={async (id) => {
- await fetch(`/api/conversations/${id}`, { method: "DELETE" });
- }}
- loadThread={async (threadId) => {
- const res = await fetch(`/api/conversations/${threadId}/messages`);
- return res.json();
- }}
- agentName="Assistant"
-/>
-```
-
-{/* add visual: flow-chart showing how threadApiUrl maps to list, create, update, delete, and load requests, and where messageFormat affects create/load payloads */}
-
-## Related guides
-
-- [The API Contract](/docs/chat/api-contract)
-- [Connecting to LLM](/docs/chat/connecting)
-- [End-to-End Guide](/docs/chat/from-scratch)
diff --git a/docs/content/docs/chat/providers.mdx b/docs/content/docs/chat/providers.mdx
deleted file mode 100644
index 034f27343..000000000
--- a/docs/content/docs/chat/providers.mdx
+++ /dev/null
@@ -1,119 +0,0 @@
----
-title: Providers
-description: Provider-specific setup for OpenAI, Vercel AI SDK, and LangGraph.
----
-
-Choose config based on the stream format and message shape your backend emits, not just the provider name.
-
-This page maps common provider and backend patterns to the matching `streamProtocol` and `messageFormat` configuration.
-
-For the core connection concepts, see [Connecting to LLM](/docs/chat/connecting).
-
-## Common mappings
-
-| Backend pattern | `streamProtocol` | `messageFormat` | Use this when... |
-| :--------------------------------------- | :------------------------------ | :-------------------------------------------- | :------------------------------------------------------------------------------- |
-| OpenUI Protocol | none | none | Your backend already emits the default OpenUI stream and accepts OpenUI messages |
-| Raw OpenAI Chat Completions SSE | `openAIAdapter()` | `openAIMessageFormat` when needed | You forward raw `data:` SSE chunks from Chat Completions |
-| OpenAI SDK `toReadableStream()` / NDJSON | `openAIReadableStreamAdapter()` | `openAIMessageFormat` when needed | You return `response.toReadableStream()` from the OpenAI SDK |
-| OpenAI Responses API | `openAIResponsesAdapter()` | `openAIConversationMessageFormat` when needed | Your backend uses `openai.responses.create()` |
-
-Start with the backend output format. Then add `messageFormat` only if the request or stored-history message shape also differs from the OpenUI default.
-
-## OpenAI Chat Completions
-
-There are two common OpenAI Chat Completions patterns.
-
-### Raw SSE
-
-Use `openAIAdapter()` if your server forwards raw Chat Completions SSE events.
-
-```tsx
-import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-
- ;
-```
-
-### OpenAI SDK `toReadableStream()`
-
-Use `openAIReadableStreamAdapter()` if your route returns `response.toReadableStream()`.
-
-```tsx
-import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-
- ;
-```
-
-## OpenAI Responses API
-
-Use `openAIResponsesAdapter()` for the Responses API event stream.
-
-Add `openAIConversationMessageFormat` only if your backend also expects or stores Responses conversation items instead of the default AG-UI message shape.
-
-```tsx
-import { openAIConversationMessageFormat, openAIResponsesAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-
- ;
-```
-
-## Vercel AI SDK
-
-Ignore the SDK name at first and inspect what your route actually returns.
-
-- If the route already speaks the OpenUI Protocol, `apiUrl` is usually enough.
-- If it returns a different stream format, keep `apiUrl` or switch to `processMessage`, then add the matching `streamProtocol`.
-- If the route expects a custom request body, use `processMessage`.
-
-## LangGraph
-
-Use the same decision rules:
-
-- start with `apiUrl` when the endpoint already matches the request and stream shape your frontend expects
-- switch to `processMessage` when you need auth headers, a custom body, dynamic routing, or provider-specific metadata
-
-`@openuidev/react-headless` ships `langGraphAdapter()` and `langGraphMessageFormat` for exactly this. Pair them with a `processMessage` that posts to a proxy route, converting messages with `langGraphMessageFormat.toApi`:
-
-```tsx
-import { langGraphAdapter, langGraphMessageFormat } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-
-
- fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ messages: langGraphMessageFormat.toApi(messages) }),
- signal: abortController.signal,
- })
- }
- streamProtocol={langGraphAdapter()}
-/>;
-```
-
-For a complete, runnable version (including a multi-agent supervisor graph and the server-side proxy that hides your API key), see the [LangGraph Chat example](/docs/openui-lang/examples/langgraph-chat).
-
-{/* add visual: flow-chart showing provider choice splitting first by emitted stream format, then by whether messageFormat is needed */}
-
-## Related guides
-
-- [Connecting to LLM](/docs/chat/connecting)
-- [Next.js Implementation](/docs/chat/nextjs)
-- [The API Contract](/docs/chat/api-contract)
diff --git a/docs/content/docs/chat/quick-start.mdx b/docs/content/docs/chat/quick-start.mdx
deleted file mode 100644
index 41ba1a8e7..000000000
--- a/docs/content/docs/chat/quick-start.mdx
+++ /dev/null
@@ -1,108 +0,0 @@
----
-title: Quick Start
-description: Get a working chat UI running in under 5 minutes.
----
-
-This page shows the scaffolded setup for getting a working chat app running quickly.
-
-If you already have an existing Next.js app, use [Installation](/docs/chat/installation) or the [End-to-End Guide](/docs/chat/from-scratch) instead.
-
-## 1. Create your app
-
-Run the create command. This scaffolds a Next.js app with OpenUI Chat already wired to an OpenAI-backed route.
-
-
- ```bash npx @openuidev/cli@latest create cd genui-chat-app ```
- ```bash pnpm dlx @openuidev/cli@latest create cd genui-chat-app ```
- ```bash yarn dlx @openuidev/cli@latest create cd genui-chat-app ```
- ```bash bunx @openuidev/cli@latest create cd genui-chat-app ```
-
-
-## 2. Add your API key
-
-Create a `.env.local` file in the project root:
-
-```bash
-OPENAI_API_KEY=sk-your-key-here
-```
-
-## 3. Start the dev server
-
-
- ```bash npm run dev ```
- ```bash pnpm dev ```
- ```bash yarn dev ```
- ```bash bun dev ```
-
-
-Open [http://localhost:3000](http://localhost:3000) in your browser. You should see the default **FullScreen** chat. Try sending a message.
-
-You should see a full-page chat experience with streaming responses enabled.
-
-{/* add visual: gif showing the generated app launching, sending a message, and streaming a response in the default scaffold */}
-
-## What you just built
-
-The scaffold generates both the frontend and backend for you.
-
-You do not need to recreate these files during quick start. This section is here so you know what the scaffold already configured.
-
-### The Frontend (`app/page.tsx`)
-
-**The** frontend renders `FullScreen`, sends requests with `processMessage`, converts messages explicitly with `openAIMessageFormat.toApi(messages)`, and parses the OpenAI SDK readable stream correctly.
-
-```tsx
-import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-import { openuiLibrary } from "@openuidev/react-ui/genui-lib";
-
-export default function Page() {
- return (
- {
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIReadableStreamAdapter()}
- componentLibrary={openuiLibrary}
- agentName="OpenUI Chat"
- />
- );
-}
-```
-
-### The Backend (`app/api/chat/route.ts`)
-
-The scaffold also creates a Next.js route handler at `app/api/chat/route.ts`.
-
-That route:
-
-- loads the system prompt generated by the CLI at build time
-- receives OpenAI-format messages
-- prepends the system prompt
-- calls OpenAI Chat Completions with streaming enabled
-- returns `response.toReadableStream()`
-
-The scaffold includes a prebuild step (`openui generate`) that creates the system prompt from your component library. This keeps the prompt on the server — it is never sent from the frontend.
-
-## Next steps
-
-Now that the app is running, choose the next path based on what you want to change.
-
-
-
- Recreate the same flow in your own existing app.
-
-
- Learn how the component library and system prompt work together.
-
-
- Build your own UI with `ChatProvider` and hooks.
-
-
diff --git a/docs/content/docs/chat/theming.mdx b/docs/content/docs/chat/theming.mdx
deleted file mode 100644
index 945c060e1..000000000
--- a/docs/content/docs/chat/theming.mdx
+++ /dev/null
@@ -1,72 +0,0 @@
----
-title: Theming
-description: Customize colors, typography, and branding for Chat components.
----
-
-Built-in chat layouts mount their own `ThemeProvider` by default. Use the `theme` prop to control mode and token overrides, or disable the built-in provider if your app already wraps the UI in its own theme scope.
-
-There are two common theming paths:
-
-- set `theme.mode` when you only need light or dark mode
-- pass `lightTheme` and `darkTheme` when you need token-level visual customization
-
-## Set the mode
-
-```tsx
-import { FullScreen } from "@openuidev/react-ui";
-
- ;
-```
-
-## Override theme tokens
-
-Use `lightTheme` and `darkTheme` inside the `theme` prop to override the built-in token sets.
-
-```tsx
-import { FullScreen, createTheme } from "@openuidev/react-ui";
-
- ;
-```
-
-If you only pass `lightTheme`, those overrides are also used as the fallback for dark mode.
-
-## Use your own app-level theme provider
-
-If your app already wraps the page in `ThemeProvider`, disable the built-in wrapper on the chat layout.
-
-```tsx
-import { FullScreen } from "@openuidev/react-ui";
-
- ;
-```
-
-`disableThemeProvider` only skips the wrapper. It does not remove any chat functionality.
-
-
-
-
Light (default)
- 
-
-
-
Dark
- 
-
-
-
-## Related guides
-
-- [FullScreen](/docs/chat/fullscreen)
-- [Copilot](/docs/chat/copilot)
-- [BottomTray](/docs/chat/bottom-tray)
diff --git a/docs/content/docs/chat/welcome.mdx b/docs/content/docs/chat/welcome.mdx
deleted file mode 100644
index ea6b7dc8b..000000000
--- a/docs/content/docs/chat/welcome.mdx
+++ /dev/null
@@ -1,91 +0,0 @@
----
-title: Welcome & Starters
-description: Configure the empty-state welcome message and conversation starters.
----
-
-When there are no messages yet, OpenUI Chat shows a welcome state. The same props work across the built-in layouts, including `Copilot`, `FullScreen`, and `BottomTray`.
-
-You can customize that empty state with:
-
-- `welcomeMessage`
-- `conversationStarters`
-
-## Basic welcome state
-
-```tsx
-import { Copilot } from "@openuidev/react-ui";
-
- ;
-```
-
-`displayText` is what users click. `prompt` is what gets sent to the model.
-
-## Custom welcome component
-
-If you want full control over the empty state, pass a React component instead of a config object.
-
-```tsx
-function CustomWelcome() {
- return (
-
-
Welcome back
-
Ask about orders, billing, or product recommendations.
-
- );
-}
-
- ;
-```
-
-## Conversation starter variants
-
-Use `variant="short"` for compact pill buttons or `variant="long"` for more descriptive list-style starters.
-
-```tsx
-
-```
-
-
-
-
`"short"` variant
- 
-
-
-
`"long"` variant
- 
-
-
-
-## Related guides
-
-- [Copilot](/docs/chat/copilot)
-- [FullScreen](/docs/chat/fullscreen)
-- [BottomTray](/docs/chat/bottom-tray)
diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json
index ddc80b45a..0030f5227 100644
--- a/docs/content/docs/meta.json
+++ b/docs/content/docs/meta.json
@@ -1,4 +1,4 @@
{
"title": "OpenUI",
- "pages": ["openui-lang", "components", "chat", "api-reference"]
+ "pages": ["openui-lang", "components", "agent", "api-reference"]
}
diff --git a/docs/content/docs/openui-lang/examples/harnesses/pi-agent-harness.mdx b/docs/content/docs/openui-lang/examples/harnesses/pi-agent-harness.mdx
index fa422d7ce..171ae97cf 100644
--- a/docs/content/docs/openui-lang/examples/harnesses/pi-agent-harness.mdx
+++ b/docs/content/docs/openui-lang/examples/harnesses/pi-agent-harness.mdx
@@ -1,9 +1,9 @@
---
-title: Pi Agent Harness
-description: Chat with the Pi coding agent (a real read/bash/edit/write agent) and get its answers as live generative UI, bridged through the Pi SDK over an OpenAI-compatible stream.
+title: pi Agent Harness
+description: Chat with the pi coding agent (a real read/bash/edit/write agent) and get its answers as live generative UI, bridged through the pi SDK over an OpenAI-compatible stream.
---
-Anything that can stream text can drive OpenUI's renderer, including a full **coding agent**. This example connects [pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (`@earendil-works/pi-coding-agent`), running its default `read` / `bash` / `edit` / `write` tools, to ``. pi's [OpenUI Lang](/docs/openui-lang/overview) instructions are appended to its system prompt, so it emits component markup instead of markdown and its streamed answers render live as generative UI.
+Anything that can stream text can drive OpenUI's renderer, including a full **coding agent**. This example connects [pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (`@earendil-works/pi-coding-agent`), running its default `read` / `bash` / `edit` / `write` tools, to ``. pi's [OpenUI Lang](/docs/openui-lang/overview) instructions are appended to its system prompt, so it emits component markup instead of markdown and its streamed answers render live as generative UI.
Its mid-turn activity (reasoning and tool runs) surfaces as cards too.
@@ -26,36 +26,42 @@ Its mid-turn activity (reasoning and tool runs) surfaces as cards too.
| Piece | File | Role |
| --- | --- | --- |
-| Frontend | `src/app/page.tsx` | A single `` with `streamProtocol={openAIReadableStreamAdapter()}`. Generates the OpenUI Lang system prompt and sends it with each turn. |
-| Bridge route | `src/app/api/chat/route.ts` | Drives a Pi `AgentSession` and re-emits its events as NDJSON OpenAI chunks (`delta.content` is OpenUI Lang). |
+| Frontend | `src/app/page.tsx` | A single `` whose `llm.send` posts to the bridge with `streamProtocol: openAIReadableStreamAdapter()`. Generates the OpenUI Lang system prompt and sends it with each turn. |
+| Bridge route | `src/app/api/chat/route.ts` | Drives a pi `AgentSession` and re-emits its events as NDJSON OpenAI chunks (`delta.content` is OpenUI Lang). |
| Session registry | `src/lib/pi-session.ts` | One persistent `AgentSession` per chat thread, keyed by the `x-conversation-id` header. |
-| Agent | `@earendil-works/pi-coding-agent` | The Pi coding agent: `read` / `bash` / `edit` / `write` on the workspace you choose at launch. |
+| Agent | `@earendil-works/pi-coding-agent` | The pi coding agent: `read` / `bash` / `edit` / `write` on the workspace you choose at launch. |
-Everything runs in **one Next.js process**: the App-Router route _is_ the backend. The Pi SDK is embedded directly (no separate server), so there is no second service and no CORS. Each chat thread maps to one persistent Pi `AgentSession`, so multi-turn context is preserved.
+Everything runs in **one Next.js process**: the App-Router route _is_ the backend. The pi SDK is embedded directly (no separate server), so there is no second service and no CORS. Each chat thread maps to one persistent pi `AgentSession`, so multi-turn context is preserved.
## Connecting the frontend
-The client is a single ``. It generates the OpenUI Lang system prompt from the component library, sends it with each turn, and parses the response with `openAIReadableStreamAdapter()` (NDJSON OpenAI chunks):
+The client is a single `` (the artifact chat surface with sidebar thread history). It generates the OpenUI Lang system prompt from the component library, sends it with each turn via its `llm.send`, and parses the response with `openAIReadableStreamAdapter()` (NDJSON OpenAI chunks):
```tsx
-import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
+import {
+ AgentInterface,
+ openAIMessageFormat,
+ openAIReadableStreamAdapter,
+ type ChatLLM,
+} from "@openuidev/react-ui";
import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-lib";
const systemPrompt = openuiLibrary.prompt(openuiPromptOptions);
- ({ id: crypto.randomUUID(), title: "New chat", createdAt: Date.now() })}
- processMessage={async ({ threadId, messages, abortController }) =>
+const llm: ChatLLM = {
+ // threadId is stable per thread, so each thread maps to its own persistent pi AgentSession.
+ send: ({ threadId, messages, signal }) =>
fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json", "x-conversation-id": threadId },
body: JSON.stringify({ systemPrompt, messages: openAIMessageFormat.toApi(messages) }),
- signal: abortController.signal,
- })
- }
- streamProtocol={openAIReadableStreamAdapter()}
+ signal,
+ }),
+ streamProtocol: openAIReadableStreamAdapter(),
+};
+
+ ;
@@ -65,7 +71,7 @@ The `systemPrompt` generated here is the **same** string the backend injects int
## The bridge route
-The route keys a persistent `AgentSession` by the `x-conversation-id` header, injects the OpenUI Lang prompt via `appendSystemPrompt`, subscribes to the session's events, and re-emits them as NDJSON OpenAI chunks. Because Pi keeps its own transcript, only the newest user turn is sent to `session.prompt()`:
+The route keys a persistent `AgentSession` by the `x-conversation-id` header, injects the OpenUI Lang prompt via `appendSystemPrompt`, subscribes to the session's events, and re-emits them as NDJSON OpenAI chunks. Because pi keeps its own transcript, only the newest user turn is sent to `session.prompt()`:
```ts
// lib/pi-session.ts: one AgentSession per conversation
@@ -76,14 +82,14 @@ const loader = new DefaultResourceLoader({
cwd,
agentDir,
settingsManager,
- appendSystemPrompt: [systemPrompt], // makes Pi speak OpenUI Lang
+ appendSystemPrompt: [systemPrompt], // makes pi speak OpenUI Lang
});
await loader.reload();
const { session } = await createAgentSession({ cwd, agentDir, settingsManager, resourceLoader: loader });
```
```ts
-// app/api/chat/route.ts: translate Pi events into OpenAI NDJSON
+// app/api/chat/route.ts: translate pi events into OpenAI NDJSON
const unsubscribe = session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
enqueue(ndjsonChunk({ content: event.assistantMessageEvent.delta }));
@@ -93,7 +99,7 @@ await session.prompt(lastUserText);
enqueue(ndjsonChunk({}, "stop"));
```
-The Pi SDK is ESM-only, so it is loaded with a native dynamic `import()` and marked as a webpack external in `next.config.ts` (the example runs with `--webpack`).
+The pi SDK is ESM-only, so it is loaded with a native dynamic `import()` and marked as a webpack external in `next.config.ts` (the example runs with `--webpack`).
## Thinking states
@@ -126,23 +132,23 @@ The agent's `read` / `bash` / `edit` / `write` tools act on that directory.
This example executes real code on your machine. The agent has the full `read` / `bash` / `edit` / `write` toolset, tools execute **without an approval prompt**, and the route is **unauthenticated**, so treat reaching the port as remote code execution.
-- Local, single-user use is equivalent to running the Pi CLI yourself.
+- Local, single-user use is equivalent to running the pi CLI yourself.
- For anything networked: set `PI_WEB_TOOLS=read-only`, put it behind auth, bind to loopback (`next start -H 127.0.0.1`), and sandbox the agent. `PI_AGENT_CWD` is a discovery root, **not** a sandbox: `bash` can escape it.
## Authentication
-The Pi SDK resolves a model from either an environment API key (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, ...) **or** an existing `~/.pi/agent` login from the Pi CLI. The Pi CLI is **not** required; an API key alone works.
+The pi SDK resolves a model from either an environment API key (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, ...) **or** an existing `~/.pi/agent` login from the pi CLI. The pi CLI is **not** required; an API key alone works.
## Project layout
```
examples/harnesses/pi-agent-harness/
-|- src/app/page.tsx # wired to openAIReadableStreamAdapter()
-|- src/app/api/chat/route.ts # Pi event stream into NDJSON OpenAI chunks
-|- src/lib/pi-session.ts # one persistent Pi AgentSession per conversation
+|- src/app/page.tsx # wired to openAIReadableStreamAdapter()
+|- src/app/api/chat/route.ts # pi event stream into NDJSON OpenAI chunks
+|- src/lib/pi-session.ts # one persistent pi AgentSession per conversation
|- src/library.ts # the OpenUI component library (re-exported)
|- scripts/launch.mjs # picks the agent workspace, then starts Next
-|- next.config.ts # keeps the ESM-only Pi SDK external
+|- next.config.ts # keeps the ESM-only pi SDK external
```
## Run the example
@@ -153,7 +159,7 @@ From the repo root, install workspace deps once, then run the example pointed at
pnpm install
cd examples/harnesses/pi-agent-harness
-cp .env.example .env # set a provider API key (skip if you have a Pi login)
+cp .env.example .env # set a provider API key (skip if you have a pi login)
pnpm dev -- /path/to/your/project
```
diff --git a/docs/content/docs/openui-lang/examples/langgraph-chat.mdx b/docs/content/docs/openui-lang/examples/langgraph-chat.mdx
index fa1395c58..72df36075 100644
--- a/docs/content/docs/openui-lang/examples/langgraph-chat.mdx
+++ b/docs/content/docs/openui-lang/examples/langgraph-chat.mdx
@@ -3,7 +3,7 @@ title: LangGraph Chat
description: A multi-agent LangGraph supervisor that routes each message to a weather, finance, or research specialist and streams OpenUI Lang into live generative UI.
---
-OpenUI's renderer is transport-agnostic: it turns a stream of OpenUI Lang markup into interactive React components no matter how that stream is produced. This example produces it with a **multi-agent [LangGraph](https://langchain-ai.github.io/langgraphjs/) graph**: a supervisor routes each user message to one of three specialists (**weather**, **finance**, or **research**), and the chosen agent streams its answer as OpenUI Lang, which `` renders into cards, tables, and charts as the tokens arrive.
+OpenUI's renderer is transport-agnostic: it turns a stream of OpenUI Lang markup into interactive React components no matter how that stream is produced. This example produces it with a **multi-agent [LangGraph](https://langchain-ai.github.io/langgraphjs/) graph**: a supervisor routes each user message to one of three specialists (**weather**, **finance**, or **research**), and the chosen agent streams its answer as OpenUI Lang, which `` renders into cards, tables, and charts as the tokens arrive.
Because every specialist shares the same OpenUI system prompt (generated from the component library), any agent you add automatically speaks generative UI.
@@ -35,32 +35,39 @@ The example runs **two processes**: the LangGraph server runs the graph (and the
## Connecting the frontend
-The client is a single ``. `processMessage` posts the conversation to the proxy, and `langGraphAdapter()` parses the LangGraph SSE stream that comes back:
+The client is a single `` — the artifact chat interface. Its `llm.send` posts the conversation to the proxy, and `langGraphAdapter()` parses the LangGraph SSE stream that comes back:
```tsx
-import { langGraphAdapter, langGraphMessageFormat } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
+import {
+ AgentInterface,
+ langGraphAdapter,
+ langGraphMessageFormat,
+ type ChatLLM,
+} from "@openuidev/react-ui";
import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
-
+const llm: ChatLLM = {
+ send: ({ messages, signal }) =>
fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
// Convert OpenUI messages to LangChain shape. The run is stateless,
// so the full history is sent each turn.
body: JSON.stringify({ messages: langGraphMessageFormat.toApi(messages) }),
- signal: abortController.signal,
- })
- }
- streamProtocol={langGraphAdapter()}
+ signal,
+ }),
+ streamProtocol: langGraphAdapter(),
+};
+
+ ;
```
-- `processMessage`: posts the conversation to the proxy; `langGraphMessageFormat.toApi` converts OpenUI messages into the LangChain message shape the graph expects
-- `streamProtocol={langGraphAdapter()}`: parses LangGraph's `messages` SSE events back into streaming assistant text
+- `llm.send`: posts the conversation to the proxy; `langGraphMessageFormat.toApi` converts OpenUI messages into the LangChain message shape the graph expects
+- `llm.streamProtocol={langGraphAdapter()}`: parses LangGraph's `messages` SSE events back into streaming assistant text
- `componentLibrary={openuiChatLibrary}`: maps OpenUI Lang nodes to the built-in component set (cards, tables, charts, forms)
## The proxy route
@@ -136,7 +143,7 @@ Add a specialist by extending the `SPECIALISTS` map and wiring a matching `*_age
```
examples/langgraph-chat/
-|- src/app/page.tsx # wired to langGraphAdapter()
+|- src/app/page.tsx # wired to langGraphAdapter()
|- src/app/api/chat/route.ts # Stateless proxy to the LangGraph server (SSE)
|- src/agent/graph.ts # Supervisor + specialist ReAct loops
|- src/agent/tools.ts # Mock weather / finance / research tools
@@ -184,5 +191,3 @@ LANGSMITH_API_KEY=lsv2-... # auth for the deployment
```
The SDK sends `LANGSMITH_API_KEY` as `x-api-key` from the server side only. Restart `pnpm dev` after changing `.env`.
-
-For the configuration-level decision between `apiUrl` and `processMessage` when wiring any LangGraph backend, see the [Providers guide](/docs/chat/providers#langgraph).
diff --git a/docs/content/docs/openui-lang/examples/shadcn-chat.mdx b/docs/content/docs/openui-lang/examples/shadcn-chat.mdx
index 77302a2b9..de299ec9c 100644
--- a/docs/content/docs/openui-lang/examples/shadcn-chat.mdx
+++ b/docs/content/docs/openui-lang/examples/shadcn-chat.mdx
@@ -64,8 +64,8 @@ See [Defining Components](/docs/openui-lang/defining-components) for the full `d
## Architecture
```
-Browser (FullScreen) -- POST /api/chat --> Next.js route --> OpenAI
- <-- SSE stream -- (OpenUI Lang + tool calls)
+Browser (AgentInterface) -- POST /api/chat --> Next.js route --> OpenAI
+ <-- SSE stream -- (OpenUI Lang + tool calls)
```
The client sends a conversation to `/api/chat`. The API route loads a generated `system-prompt.txt`, forwards the messages to the LLM with streaming and tool definitions, and returns SSE events. On the client, `openAIAdapter()` parses the SSE stream and `shadcnChatLibrary` maps each OpenUI Lang node to a shadcn/ui component that renders progressively as tokens arrive.
diff --git a/docs/content/docs/openui-lang/index.mdx b/docs/content/docs/openui-lang/index.mdx
index efa5eb440..0328507e7 100644
--- a/docs/content/docs/openui-lang/index.mdx
+++ b/docs/content/docs/openui-lang/index.mdx
@@ -48,9 +48,6 @@ OpenUI Lang was created to solve these core issues:
## What can you build?
-
- Conversational AI with generative UI responses, thread history, and prebuilt layouts.
-
Data-driven dashboards, CRUD interfaces, and monitoring tools, powered by live data from your
tools.
diff --git a/docs/content/docs/openui-lang/overview.mdx b/docs/content/docs/openui-lang/overview.mdx
index f1bd067b0..0457e1d2b 100644
--- a/docs/content/docs/openui-lang/overview.mdx
+++ b/docs/content/docs/openui-lang/overview.mdx
@@ -38,15 +38,14 @@ const systemPrompt = openuiLibrary.prompt(openuiPromptOptions);
Root component is `Card` (vertical container, no layout params). Adds chat-specific components like `FollowUpBlock`, `ListBlock`, and `SectionBlock`. Does not include `Stack`; responses are always single-card, vertically stacked.
-```ts
-import { openuiChatLibrary, openuiChatPromptOptions } from "@openuidev/react-ui/genui-lib";
-import { FullScreen } from "@openuidev/react-ui";
+```tsx
+import { AgentInterface, openAIAdapter } from "@openuidev/react-ui";
+import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
-// Use with a chat layout
-
```
diff --git a/docs/content/docs/openui-lang/quickstart.mdx b/docs/content/docs/openui-lang/quickstart.mdx
index 11ae7f6aa..4224909f9 100644
--- a/docs/content/docs/openui-lang/quickstart.mdx
+++ b/docs/content/docs/openui-lang/quickstart.mdx
@@ -40,7 +40,7 @@ The CLI generates a Next.js app with everything wired up:
```
src/
app/
- page.tsx # FullScreen chat layout with the built-in component library
+ page.tsx # AgentInterface chat (thread history) with the built-in component library
api/chat/
route.ts # Backend route with OpenAI streaming + example tools
library.ts # Re-exports openuiChatLibrary and openuiChatPromptOptions
@@ -48,7 +48,7 @@ src/
system-prompt.txt # Auto-generated at build time via `openui generate`
```
-- **`page.tsx`**: Renders the `FullScreen` chat layout with `openuiChatLibrary` for Generative UI rendering and `openAIAdapter()` for streaming.
+- **`page.tsx`**: Renders the `AgentInterface` chat (an artifact chat surface with thread history) with `openuiChatLibrary` for Generative UI rendering. It provides an `llm` whose `send` posts messages to the route and whose `streamProtocol` is `openAIAdapter()`. Storage is optional — omit it and threads are kept in memory (wiped on reload).
- **`route.ts`**: A backend API route that sends the system prompt to the LLM and streams the response back.
- **`library.ts`**: Your component library entrypoint. The `openui generate` CLI reads this file to produce the system prompt.
diff --git a/docs/public/AGENTS.md b/docs/public/AGENTS.md
new file mode 100644
index 000000000..0ac348586
--- /dev/null
+++ b/docs/public/AGENTS.md
@@ -0,0 +1,776 @@
+# OpenUI Agent Interface: guide for coding agents
+
+OpenUI Agent Interface (`@openuidev/react-ui`) builds a complete streaming chat in
+React: generative-UI components rendered inline, durable artifacts (reports,
+slides, dashboards) in side panels, conversation history, and a mobile layout. It runs on **OpenUI Cloud** (a managed backend) or your **own backend**.
+
+This file is self-contained: everything needed to build an agent app is here.
+Everything imports from `@openuidev/react-ui`. Read the Rules first.
+
+## Rules
+
+- **Import everything from `@openuidev/react-ui`.** It re-exports the headless
+ package. Never import from `@openuidev/react-headless`.
+- **`` requires `llm`.** `storage` is optional (in-memory default,
+ wiped on reload). `componentLibrary` turns on generative UI.
+- **Stream adapters and message formats are factories. Call them.**
+ `openAIResponsesAdapter()`, never bare `openAIResponsesAdapter`.
+- **Pair the adapter with its message format.** `openAIResponsesAdapter()` with
+ `openAIConversationMessageFormat`; `openAIAdapter()` or
+ `openAIReadableStreamAdapter()` with `openAIMessageFormat`; `agUIAdapter()` with
+ `identityMessageFormat` (the default); `langGraphAdapter()` with
+ `langGraphMessageFormat`.
+- **OpenUI Cloud is two planes.** `llm` is a `ChatLLM` whose `send` posts to your
+ own `/api/chat` route, which proxies to Cloud with `THESYS_API_KEY`. `storage`
+ is `useOpenuiCloudStorage({ token: "/api/frontend-token" })`. `THESYS_API_KEY`
+ is server-side only, never in the browser.
+- **On Cloud, send only the latest message.** The Responses API replays history
+ from the conversation: `input: openAIConversationMessageFormat.toApi(messages.slice(-1))`.
+- **For Cloud, the component set, storage hook, and artifacts come from
+ `@openuidev/thesys`** (`chatLibrary`, `useOpenuiCloudStorage`,
+ `artifactRenderers`, `artifactCategories`); the server route uses
+ `@openuidev/thesys-server` (`artifactTool`, `createResponsesInstructions`).
+- **Send a message programmatically** with `useThread`:
+ `const send = useThread((s) => s.processMessage); send({ role: "user", content })`.
+- **An artifact `parser` must tolerate partial data.** While streaming, `args` is
+ a partial JSON string and `response` is `null` until the tool result lands.
+ Return `null` to skip.
+- **Build artifact props with `defineArtifactCategories(...)` and spread the
+ result** onto ``. Do not hand-write the `artifactCategories` array.
+- **Hooks only work inside ``**, and all import from
+ `@openuidev/react-ui` (including `useNav`).
+- **Import the CSS once.** `@openuidev/react-ui/components.css`, plus
+ `@openuidev/thesys/styles.css` on Cloud.
+
+## Install
+
+```bash
+npm install @openuidev/react-ui
+# OpenUI Cloud also:
+npm install @openuidev/thesys @openuidev/thesys-server
+# Authoring your own GenUI components:
+npm install @openuidev/react-lang zod
+# The `openui generate` CLI (build-time system-prompt generation):
+npm install -D @openuidev/cli
+```
+
+## Quickstart: OpenUI Cloud
+
+Three files: the client mount, a generation proxy, and a token mint.
+
+```tsx
+// app/page.tsx
+"use client";
+import "@openuidev/react-ui/components.css";
+import "@openuidev/thesys/styles.css";
+
+import {
+ AgentInterface,
+ openAIConversationMessageFormat,
+ openAIResponsesAdapter,
+ type ChatLLM,
+} from "@openuidev/react-ui";
+import {
+ chatLibrary,
+ useOpenuiCloudStorage,
+ artifactRenderers,
+ artifactCategories,
+} from "@openuidev/thesys";
+
+const llm: ChatLLM = {
+ // Cloud replays history from the conversation, so send only the latest message.
+ send: ({ threadId, messages, signal }) =>
+ fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ threadId,
+ input: openAIConversationMessageFormat.toApi(messages.slice(-1)),
+ }),
+ signal,
+ }),
+ streamProtocol: openAIResponsesAdapter(),
+};
+
+export default function App() {
+ const storage = useOpenuiCloudStorage({
+ token: "/api/frontend-token",
+ features: { artifact: true },
+ });
+
+ return (
+
+ );
+}
+```
+
+```ts
+// app/api/chat/route.ts: proxies to OpenUI Cloud. THESYS_API_KEY stays server-side.
+import { artifactTool, createResponsesInstructions } from "@openuidev/thesys-server";
+
+export async function POST(req: Request) {
+ const { threadId, input } = await req.json();
+ const upstream = await fetch("https://api.thesys.dev/v1/embed/responses", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${process.env.THESYS_API_KEY}`,
+ },
+ body: JSON.stringify({
+ model: "openai/gpt-5",
+ conversation: threadId, // Cloud stores and replays the conversation
+ input,
+ stream: true,
+ store: true,
+ tools: [artifactTool()], // managed slides/report tool
+ instructions: createResponsesInstructions(),
+ }),
+ signal: req.signal, // forward browser aborts (stop button)
+ });
+ // Pipe the SSE stream straight through.
+ return new Response(upstream.body, {
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform" },
+ });
+}
+```
+
+```ts
+// app/api/frontend-token/route.ts: mints a short-lived browser token for storage reads.
+export async function POST() {
+ const res = await fetch("https://api.thesys.dev/v1/frontend-tokens", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${process.env.THESYS_API_KEY}`,
+ },
+ body: JSON.stringify({ user_id: "" }),
+ });
+ const { token, expires_at } = await res.json();
+ return Response.json({ token, expires_at });
+}
+```
+
+```bash
+# .env.local
+THESYS_API_KEY=sk-th-your-key # server-side only
+```
+
+## Quickstart: self-hosted
+
+```tsx
+// app/page.tsx
+"use client";
+import "@openuidev/react-ui/components.css";
+import {
+ AgentInterface,
+ fetchLLM,
+ restStorage,
+ openAIReadableStreamAdapter,
+ openAIMessageFormat,
+} from "@openuidev/react-ui";
+import { openuiLibrary } from "@openuidev/react-ui/genui-lib";
+
+const llm = fetchLLM({
+ url: "/api/chat",
+ streamAdapter: openAIReadableStreamAdapter(),
+ messageFormat: openAIMessageFormat,
+});
+const storage = restStorage({ baseUrl: "/api/threads" }); // optional; omit for in-memory
+
+export default function App() {
+ return ;
+}
+```
+
+```ts
+// app/api/chat/route.ts
+import OpenAI from "openai";
+import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-lib";
+
+const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
+const systemPrompt = openuiLibrary.prompt(openuiPromptOptions); // teaches the model the components
+
+export async function POST(req: Request) {
+ const { messages } = await req.json();
+ const stream = await openai.chat.completions.create({
+ model: "gpt-5",
+ messages: [{ role: "system", content: systemPrompt }, ...messages],
+ stream: true,
+ });
+ // openAIReadableStreamAdapter() parses this NDJSON.
+ return new Response(stream.toReadableStream(), {
+ headers: { "Content-Type": "application/x-ndjson" },
+ });
+}
+```
+
+`fetchLLM` POSTs `{ threadId, messages: messageFormat.toApi(messages) }` to `url`
+and forwards the abort signal. Here `messages` is the **full thread history** (the
+SDK loads it from `storage` and holds it client-side), so forward all of it to your
+provider. (Contrast Cloud, where you send only the latest because Cloud replays the
+conversation.) Your route must **stream** and **close the stream when done** (the
+client's `isRunning` flips back to `false` only on close).
+
+## `` props
+
+Required:
+- `llm: ChatLLM`: produces replies.
+
+Common:
+- `storage?: ChatStorage`: persistence (in-memory default, wiped on reload).
+- `componentLibrary?: Library`: turns on generative UI.
+- `artifactRenderers?: ArtifactRendererConfig[]`: custom artifact renderers.
+- `artifactCategories?: ArtifactCategory[]`: sidebar artifact nav groups.
+- `components?: { AssistantMessage?, UserMessage? }`: replace message rendering.
+- `agentName?: string`, `logoUrl?: string`, `labels?: AgentInterfaceLabels`.
+- `starters?: ConversationStarterProps[]`, `starterVariant?: "short" | "long"`.
+
+Routing and scroll:
+- `path?` / `defaultPath?` / `onNavigate?`: pass `onNavigate` for controlled nav.
+- `scrollVariant?: "always" | "user-message-anchor"` (default `user-message-anchor`).
+- `scrollOnLoad?: boolean` (default `true`).
+
+Children are slot overrides (see Customization).
+
+## Backends: `ChatLLM` and adapters
+
+The `llm` is a `ChatLLM`. `fetchLLM` builds one for the common case; for full
+control, write the object directly (this is what the Cloud quickstart does).
+
+```ts
+interface ChatLLM {
+ send(p: { threadId: string; messages: Message[]; signal: AbortSignal }): Promise;
+ streamProtocol: StreamProtocolAdapter; // the adapter that parses the response stream
+}
+
+// Factory for the common case:
+fetchLLM({ url, streamAdapter, messageFormat?, headers?, fetch? }): ChatLLM;
+```
+
+Stream adapters (all factories, call with `()`) and the message format each pairs with:
+
+| Adapter | Wire format | Message format |
+|---|---|---|
+| `agUIAdapter()` | AG-UI SSE (see AG-UI events) | `identityMessageFormat` (default) |
+| `openAIAdapter()` | OpenAI Chat Completions SSE | `openAIMessageFormat` |
+| `openAIReadableStreamAdapter()` | OpenAI SDK `toReadableStream()` NDJSON | `openAIMessageFormat` |
+| `openAIResponsesAdapter()` | OpenAI Responses SSE | `openAIConversationMessageFormat` |
+| `langGraphAdapter(opts?)` | LangGraph named SSE | `langGraphMessageFormat` |
+
+A `MessageFormat` is `{ toApi(messages): unknown; fromApi(data): Message[] }`.
+`toApi` shapes outgoing messages for your provider; `fromApi` parses stored
+messages back. `identityMessageFormat` passes `Message[]` through unchanged.
+
+## Storage
+
+`storage` is a `ChatStorage` with a required `thread` channel and an optional
+`artifact` channel.
+
+```ts
+interface ChatStorage { thread: ThreadStorage; artifact?: ArtifactStorage; }
+
+interface ThreadStorage {
+ listThreads(cursor?: string): Promise<{ threads: Thread[]; nextCursor?: string }>;
+ createThread(firstMessage: UserMessage): Promise;
+ getMessages(threadId: string): Promise;
+ updateThread(thread: Thread): Promise;
+ deleteThread(id: string): Promise;
+}
+
+interface ArtifactStorage {
+ list(params?: ArtifactListParams): Promise<{ artifacts: ArtifactSummary[]; nextCursor?: string }>;
+ get(id: string): Promise;
+ update(patch: { id: string; content: unknown }): Promise;
+}
+
+// ArtifactSummary = { id, title, type, threadId, updatedAt? }
+// Artifact extends ArtifactSummary { content: unknown }
+// Thread = { id, title, createdAt: string | number, isPending? }
+```
+
+`Message` is a discriminated union on `role` (re-exported from `@ag-ui/core`); every
+message carries an `id`:
+
+```ts
+type Message =
+ | { id: string; role: "user"; content: string | InputContent[] } // array = multimodal
+ | { id: string; role: "assistant"; content?: string; toolCalls?: ToolCall[] }
+ | { id: string; role: "tool"; toolCallId: string; content: string }
+ | { id: string; role: "system" | "developer" | "reasoning"; content: string };
+```
+
+`restStorage({ baseUrl, messageFormat?, headers?, fetch? })` implements the
+`thread` channel against this fixed REST contract (no `artifact` channel):
+
+| Operation | Method + path | Body | Returns |
+|---|---|---|---|
+| List threads | `GET {baseUrl}/get` (`?cursor=` to paginate) | none | `{ threads, nextCursor? }` |
+| Create thread | `POST {baseUrl}/create` | `{ messages: [...] }` (first user message) | the new `Thread` |
+| Get messages | `GET {baseUrl}/get/{threadId}` | none | `Message[]` |
+| Update thread | `PATCH {baseUrl}/update/{threadId}` | the full `Thread` | the updated `Thread` |
+| Delete thread | `DELETE {baseUrl}/delete/{threadId}` | none | nothing |
+
+`restStorage` applies `messageFormat` to the `/create` body (`toApi`) and the
+`/get/{id}` response (`fromApi`), so it can match a backend that stores a
+provider-specific shape; the default is identity (no transform).
+
+For anything else, pass a hand-written `ChatStorage`. To add an artifact channel,
+supply `storage.artifact` yourself (restStorage does not).
+
+A minimal Next.js App Router backend for the `restStorage` contract (back the
+`Map` with your database):
+
+```ts
+// app/api/threads/[...path]/route.ts
+import type { Thread, Message } from "@openuidev/react-ui";
+
+const store = new Map(); // use your DB
+
+export async function GET(_req: Request, { params }: { params: Promise<{ path: string[] }> }) {
+ const { path } = await params;
+ if (path[0] === "get" && path[1]) return Response.json(store.get(path[1])?.messages ?? []);
+ return Response.json({ threads: [...store.values()].map((r) => r.thread) }); // list
+}
+export async function POST(req: Request) {
+ const { messages } = await req.json(); // create: body is { messages: [firstUserMessage] }
+ const thread: Thread = { id: crypto.randomUUID(), title: "New chat", createdAt: Date.now() };
+ store.set(thread.id, { thread, messages });
+ return Response.json(thread);
+}
+export async function PATCH(req: Request, { params }: { params: Promise<{ path: string[] }> }) {
+ const { path } = await params;
+ const thread: Thread = await req.json();
+ const row = store.get(path[1]);
+ if (row) row.thread = thread;
+ return Response.json(thread);
+}
+export async function DELETE(_req: Request, { params }: { params: Promise<{ path: string[] }> }) {
+ const { path } = await params;
+ store.delete(path[1]);
+ return new Response(null, { status: 204 });
+}
+```
+
+On Cloud, `useOpenuiCloudStorage({ token, features?, apiBaseUrl? })` provides both
+the thread and artifact channels. `features.artifact` defaults to `true` (omit
+`features` to keep artifacts on).
+
+## Generative UI
+
+The agent renders components from a `Library` inline, streaming their props as
+they arrive. Pass the library as `componentLibrary`.
+
+```tsx
+import { AgentInterface } from "@openuidev/react-ui";
+import { openuiLibrary } from "@openuidev/react-ui/genui-lib";
+
+ ;
+```
+
+`openuiLibrary` (web) / `openuiChatLibrary` cover layout, content, tables, charts,
+forms, and buttons. On Cloud use `chatLibrary` from `@openuidev/thesys`. Each
+library ships paired prompt options (`openuiPromptOptions`,
+`openuiChatPromptOptions`).
+
+The model must be told what components exist. Generate a system prompt from the
+library and send it to your provider (self-hosted): `library.prompt(promptOptions)`
+(see the self-hosted route above). On Cloud, `createResponsesInstructions()` does this.
+
+### Authoring your own components
+
+Use `@openuidev/react-lang`. A component is a Zod v4 schema (props) plus a React
+renderer. `createLibrary` bundles components into a `Library`.
+
+```tsx
+import { z } from "zod";
+import { defineComponent, createLibrary } from "@openuidev/react-lang";
+
+const StatCard = defineComponent({
+ name: "StatCard",
+ description: "A single KPI with a label, value, and optional delta percentage.",
+ props: z.object({
+ label: z.string(),
+ value: z.string(),
+ delta: z.number().optional(),
+ }),
+ component: ({ props }) => (
+
+ {props.label}
+ {props.value}
+ {props.delta != null && {props.delta}% }
+
+ ),
+});
+
+export const library = createLibrary({ components: [StatCard] });
+export const promptOptions = { additionalRules: ["Prefer StatCard for single metrics."] };
+```
+
+- Props **must be a Zod v4 object** (Zod 3 throws). `description` is what the model sees.
+- The renderer is `React.FC<{ props, renderNode, statementId? }>`. Because it contains JSX, name the library file `.tsx`.
+- **Nesting:** reference another component with `Child.ref` and render it with
+ `renderNode`:
+
+```tsx
+const Item = defineComponent({
+ name: "Item",
+ description: "A list row.",
+ props: z.object({ text: z.string() }),
+ component: ({ props }) => {props.text} ,
+});
+
+const List = defineComponent({
+ name: "List",
+ description: "A bulleted list of Items.",
+ props: z.object({ children: z.array(Item.ref) }),
+ component: ({ props, renderNode }) => {renderNode(props.children)} ,
+});
+```
+
+`createLibrary({ components, componentGroups?, root? })` returns a `Library` with
+`prompt(options?)`, `toSpec()`, and `toJSONSchema()`. `library.prompt(options)`
+takes `PromptOptions`:
+
+```ts
+interface PromptOptions {
+ preamble?: string;
+ additionalRules?: string[];
+ examples?: string[]; // static/layout patterns
+ toolExamples?: string[]; // shown when tools present
+ tools?: (string | ToolSpec)[];
+ editMode?: boolean; inlineMode?: boolean;
+ toolCalls?: boolean; // default true when tools provided
+ bindings?: boolean; // $variables/@Set/@Reset; default true if toolCalls
+}
+```
+
+Generate the system prompt at build time with the CLI:
+
+```bash
+openui generate src/library.tsx --out src/system-prompt.txt
+openui generate src/library.tsx --json-schema -o spec.json # component signatures
+```
+
+Mount your library: `componentLibrary={library}`, and feed
+`library.prompt(promptOptions)` to your model as the system prompt.
+
+### Interactivity
+
+Generated components can be interactive. From `@openuidev/react-lang`:
+`reactive(schema)` marks a prop as accepting a `$variable` binding;
+`useStateField(name, value?)` reads/writes form state; `useTriggerAction()` fires
+actions (`@Set`, `@Run`, `@ToAssistant`). Helpers: `useFormName`,
+`useGetFieldValue`, `useSetFieldValue`, `useIsQueryLoading`, `useIsStreaming`.
+Form state is tracked automatically and saved with the thread.
+
+## Artifacts
+
+An artifact is a durable output (report, slide deck, dashboard, code file): an
+inline preview in chat plus a full view in a side panel or page. A renderer
+matches a tool call by `toolName` and a stored artifact by `type`.
+
+```tsx
+import { defineArtifactRenderer } from "@openuidev/react-ui";
+
+const reportRenderer = defineArtifactRenderer({
+ type: "report",
+ toolName: "create_report", // string | string[]; first registration wins on a dup
+ parser: ({ args, response }, { isStreaming }) => {
+ const data = response as { id: string; title: string; body: string } | null;
+ if (!data) return null; // tolerate partial data while streaming
+ return {
+ props: data,
+ // meta null = render without registering in the thread (common while streaming)
+ meta: isStreaming ? null : { id: data.id, version: 1, heading: data.title },
+ };
+ },
+ preview: (props, controls) => ,
+ actual: (props) => ,
+ icon: ,
+ label: "Report",
+});
+```
+
+Parser contract (two paths, same renderer):
+- **Tool-call path:** `{ args, response }` exactly as the backend emitted them.
+ `args` is a partial JSON string while the LLM streams; `response` is `null`
+ until the tool result arrives (`isStreaming` is `true` until then). The SDK does
+ not pre-parse JSON.
+- **Storage path:** `{ args: undefined, response: artifact.content }`, `isStreaming`
+ false. Stored `content` must match the tool-call response shape.
+- Return `null` to skip. Return `meta: null` to render without registering.
+ `meta.id` must be stable across re-runs; `meta.type` overrides the static `type`
+ for registration. The same component instance is reused across the
+ streaming → complete transition (swap UI on `controls.isStreaming`, no remount).
+
+The parser returns `ParsedArtifact` (or `null`):
+
+```ts
+interface ParsedArtifact {
+ props: Props;
+ meta: { id: string; version: number; heading: string; type?: string } | null;
+}
+```
+
+- `id`: stable identity across re-runs. Supply it from the tool result (include an `id` field) or derive it from stable content (e.g. a slug of the title); it must not change when the same artifact re-renders.
+- `version`: bump when content changes for the same `id`; an `(id, version)` change re-registers the entry.
+- `heading`: label shown in the workspace and artifact lists.
+- `type?`: override the renderer's static `type` for this entry (one tool-owning renderer can register entries under different kinds).
+- `meta: null`: render preview/actual but skip thread registration (use while streaming).
+
+`controls` passed to `preview`/`actual`:
+`{ isActive, isStreaming, open(), close(), toggle() }`.
+
+Group renderers into sidebar categories with `defineArtifactCategories`. It
+returns both props (renderers and types deduped); spread them onto the component.
+
+```tsx
+import { AgentInterface, defineArtifactCategories } from "@openuidev/react-ui";
+
+const artifacts = defineArtifactCategories([
+ { name: "Reports", renderers: [reportRenderer], icon: },
+ { name: "Dashboards", renderers: [dashboardRenderer], icon: },
+]);
+
+ ;
+// artifacts === { artifactRenderers, artifactCategories }
+```
+
+On OpenUI Cloud, the agent produces **slides and reports** with no renderer to
+write: the managed `artifactTool()` (server) plus `artifactRenderers` /
+`artifactCategories` from `@openuidev/thesys` (client) cover them. To add your own
+artifact types on Cloud, compose: pass your custom renderers and categories
+alongside the managed ones (`artifactRenderers={[...artifactRenderers, myRenderer]}`,
+`artifactCategories={[...artifactCategories, ...myCategories]}`); a custom
+`componentLibrary` works the same way (all just `` props). The
+renderer side is reliable on Cloud. The tool that *produces* a custom artifact's
+content, though, is only fully worked in the self-hosted loop above: the managed
+`artifactTool()` generates slides/reports inside Cloud, but running your own
+artifact-producing tool on Cloud (catching its streamed call and returning the
+content into the conversation) is an advanced, less-trodden path.
+
+## Tools
+
+A tool is a `name`, a `description`, and a JSON Schema for its arguments. The model
+proposes a call; **your code runs it** (on Cloud and self-hosted alike, your code
+always runs your own tools); the result returns to the conversation. Only Cloud's
+built-in tools run inside Cloud.
+
+```ts
+const tools = [
+ {
+ type: "function",
+ name: "get_weather",
+ description: "Get the current weather for a city.",
+ parameters: {
+ type: "object",
+ properties: { city: { type: "string", description: "City name" } },
+ required: ["city"],
+ additionalProperties: false,
+ },
+ },
+];
+```
+
+**Self-hosted loop:** in your route, declare the tools to your provider, run the
+ones it asks for, append each result, and call the provider again until it returns
+a turn with no tool calls. If your route emits AG-UI events (with `agUIAdapter()`),
+emit `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` / `TOOL_CALL_RESULT`
+around each call between the text events (see AG-UI events).
+
+```ts
+// app/api/chat/route.ts (self-hosted, with tools)
+import OpenAI from "openai";
+const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
+
+const run: Record Promise> = {
+ get_weather: async ({ city }) => ({ tempC: 22, sky: "clear" }), // your real impl
+};
+
+export async function POST(req: Request) {
+ const { messages } = await req.json();
+ const convo = [...messages];
+ while (true) {
+ const res = await openai.chat.completions.create({ model: "gpt-5", messages: convo, tools });
+ const msg = res.choices[0].message;
+ if (!msg.tool_calls?.length) {
+ // No more tool calls: stream the final answer back to the client.
+ const final = await openai.chat.completions.create({ model: "gpt-5", messages: convo, stream: true });
+ return new Response(final.toReadableStream(), { headers: { "Content-Type": "application/x-ndjson" } });
+ }
+ convo.push(msg);
+ for (const call of msg.tool_calls) {
+ const out = await run[call.function.name](JSON.parse(call.function.arguments));
+ convo.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(out) });
+ }
+ }
+}
+```
+
+On Cloud, the managed `artifactTool()` runs inside Cloud. You can declare extra
+function tools in the `/api/chat` `tools` array, but executing them means catching
+the streamed tool call in your app and submitting the result back to Cloud's
+Responses API (an advanced pattern not worked here). For a fully worked custom-tool
+loop, use the self-hosted route above.
+
+A tool call is also how a custom artifact is produced: name the tool to match a
+renderer's `toolName`.
+
+## Customization (slots and primitives)
+
+Pass children to override regions. Every slot and primitive is a **static property
+of `AgentInterface`** (``, ``,
+``, and so on), not a separate import. **Slot markers**
+replace a whole region; **primitives** compose inside a slot. Omit a slot to get
+the default.
+
+Slot markers: `Sidebar`, `SidebarHeader`, `MobileHeader`, `ThreadHeader`,
+`Welcome`, `Composer`, `Workspace`, `Route`.
+Primitives: `SidebarItem`, `SidebarContent`, `SidebarSeparator`, `NewChatButton`,
+`ThreadList`, `ArtifactNav`, `Messages`, `MessageLoading`, `ScrollArea`.
+
+```tsx
+
+
+
+
+ } path="/home">Home
+
+
+
+
+ {/* exact-match custom route; its children replace the thread region */}
+
+
+
+
+```
+
+- **`Sidebar`**: omit for the default (header + new-chat + artifact nav + thread
+ list). With children, you compose the inner content from primitives.
+- **`SidebarItem`**: `{ icon?, trailing?, selected?, path?, children }`. With
+ `path`, clicking navigates and the item auto-selects when the route matches.
+- **`ArtifactNav`**: `{ className?, icon? }`. One item per `artifactCategories`
+ (or a single "Artifacts"). Renders nothing without `storage.artifact`.
+- **`Welcome`**: `{ title?, description?, image?, starters?, starterVariant? }`
+ XOR `{ children }`. Shown only while the thread is empty.
+- **`Composer`**: `{ className?, placeholder?, starters?, starterVariant?, children? }`.
+- **`Workspace`**: the artifact rail. Omit for the default; pass children to
+ replace. Hidden on mobile and on Route/artifact pages.
+- **`Route`**: `{ path, children }`, **exact match only**. Active route children
+ replace the thread region. Pair with `useNav()` to navigate.
+- **`Messages`**: `{ loader?, assistantMessage?, userMessage? }`.
+
+### Starters
+
+```tsx
+ }]}
+ starterVariant="long" // "short" pills | "long" vertical list
+/>;
+```
+
+Each starter is `{ displayText, prompt, icon? }`. Clicking one sends `prompt` as a
+user message. Starters set on `AgentInterface` flow into Welcome and Composer; pass
+`[]` to a slot to suppress them there.
+
+### Custom message rendering
+
+```tsx
+import { AgentInterface, type AssistantMessage } from "@openuidev/react-ui";
+
+function CustomAssistantMessage({ message, isStreaming }: {
+ message: AssistantMessage;
+ isStreaming: boolean;
+}) {
+ return {message.content ?? ""}
; // content empty until first token
+}
+
+ ;
+```
+
+`components.AssistantMessage` / `components.UserMessage` are independent; override
+one and the other keeps its default. When `componentLibrary` is set and you do not
+override `AssistantMessage`, assistant messages render through the library.
+
+## Hooks
+
+All import from `@openuidev/react-ui`. Call only inside `` (a
+renderer's `preview`/`actual`, slot children, message components, and `Route`
+children are all inside the tree).
+
+| Hook | Returns |
+|---|---|
+| `useNav()` | `{ path: string \| undefined; navigate(next): void }` |
+| `useThread(selector?)` | `{ messages, isRunning, isLoadingMessages, threadError, executingToolCallIds, processMessage, appendMessages, updateMessage, setMessages, deleteMessage, cancelMessage }` |
+| `useThreadList(selector?)` | `{ threads, isLoadingThreads, selectedThreadId, hasMoreThreads, loadThreads, loadMoreThreads, switchToNewThread, createThread, selectThread, updateThread, deleteThread }` |
+| `useMessage()` | `{ message: Message }` |
+| `useArtifactList(filter?)` | per-thread artifact registry, optionally filtered by `type` |
+| `useArtifactRenderer(toolName)` | the matched `ArtifactRendererConfig` or `null` |
+| `useArtifactRendererRegistry()` | `{ byToolName, byType } \| null` (escape hatch) |
+| `useArtifactStorage()` | the `ArtifactStorage` adapter or `null` |
+| `useArtifactCategories()` | `ArtifactCategory[]` |
+| `useDetailedView(viewId)` / `useActiveDetailedView()` | the detailed-view (side panel) system |
+| `useToolActivities(message, allMessages)` | `ToolActivity[]` for a message |
+
+`useThread` and `useThreadList` are selector hooks: pass a function that picks a
+slice; the component re-renders only when that slice changes. Send a message:
+`const send = useThread((s) => s.processMessage); send({ role: "user", content })`
+(`content` is a string, or an array for multimodal input).
+
+## AG-UI events (custom streaming backends)
+
+When your route streams AG-UI SSE (paired with `agUIAdapter()`), the SDK consumes
+this event subset. Emit them in order; a run ends when the stream closes.
+
+- `TEXT_MESSAGE_START` `{ messageId, role }`
+- `TEXT_MESSAGE_CONTENT` `{ messageId, delta }` (or `TEXT_MESSAGE_CHUNK`)
+- `TOOL_CALL_START` `{ toolCallId, toolCallName }`
+- `TOOL_CALL_ARGS` `{ toolCallId, delta }` (arguments stream as a partial JSON string)
+- `TOOL_CALL_END` `{ toolCallId }`
+- `TOOL_CALL_RESULT` `{ messageId, toolCallId, content }` (`content` is always a string)
+- `RUN_ERROR` `{ message, code? }`
+
+Tool status progresses `streaming → executing → complete | error`. The event types
+are re-exported from `@openuidev/react-ui` (originally `@ag-ui/core`).
+
+A `messageId` groups one assistant text message; a `toolCallId` groups one tool
+call (independent ids). For a turn that calls one tool, then replies:
+
+```
+TOOL_CALL_START { toolCallId: "t1", toolCallName: "get_weather" }
+TOOL_CALL_ARGS { toolCallId: "t1", delta: "{\"city\":\"Tokyo\"}" }
+TOOL_CALL_END { toolCallId: "t1" }
+TOOL_CALL_RESULT { messageId: "m1", toolCallId: "t1", content: "22C, clear" }
+TEXT_MESSAGE_START { messageId: "m2", role: "assistant" }
+TEXT_MESSAGE_CONTENT { messageId: "m2", delta: "It is 22C in Tokyo." }
+(then close the stream)
+```
+
+Tool and text events may interleave across rounds in one turn. Keep each tool's
+START/ARGS/END/RESULT together, and each text message's START/CONTENT under one
+`messageId`.
+
+## Factory and type reference
+
+```ts
+fetchLLM({ url, streamAdapter, messageFormat?, headers?, fetch? }): ChatLLM
+restStorage({ baseUrl, messageFormat?, headers?, fetch? }): ChatStorage // thread channel only
+defineArtifactRenderer(config): ArtifactRendererConfig
+defineArtifactCategories(groups: ArtifactCategoryGroup[]): { artifactRenderers, artifactCategories }
+
+interface ArtifactCategoryGroup { name: string; renderers: ArtifactRendererConfig[]; icon?: ReactNode; } // builder input
+interface ArtifactCategory { name: string; filter: { type: string[] }; icon?: ReactNode; } // builder output
+type CreateMessage = { role: "user"; content: string }; // processMessage input
+```
+
+Env: OpenUI Cloud uses `THESYS_API_KEY` (server-side only); self-hosted uses your
+provider key (e.g. `OPENAI_API_KEY`), also server-side only.
diff --git a/docs/public/docs/images/chat/bottom-tray.gif b/docs/public/docs/images/chat/bottom-tray.gif
deleted file mode 100644
index 951c01e1f..000000000
Binary files a/docs/public/docs/images/chat/bottom-tray.gif and /dev/null differ
diff --git a/docs/public/docs/images/chat/bottom-tray.png b/docs/public/docs/images/chat/bottom-tray.png
deleted file mode 100644
index 380ba596c..000000000
Binary files a/docs/public/docs/images/chat/bottom-tray.png and /dev/null differ
diff --git a/docs/public/docs/images/chat/copilot.png b/docs/public/docs/images/chat/copilot.png
deleted file mode 100644
index e3fa8d5e1..000000000
Binary files a/docs/public/docs/images/chat/copilot.png and /dev/null differ
diff --git a/docs/public/docs/images/chat/fullscreen-dark.png b/docs/public/docs/images/chat/fullscreen-dark.png
deleted file mode 100644
index d8c484aa9..000000000
Binary files a/docs/public/docs/images/chat/fullscreen-dark.png and /dev/null differ
diff --git a/docs/public/docs/images/chat/fullscreen.png b/docs/public/docs/images/chat/fullscreen.png
deleted file mode 100644
index 70259d49b..000000000
Binary files a/docs/public/docs/images/chat/fullscreen.png and /dev/null differ
diff --git a/docs/public/images/agent/cloud-interface-with-artifacts.png b/docs/public/images/agent/cloud-interface-with-artifacts.png
new file mode 100644
index 000000000..9914eb90d
Binary files /dev/null and b/docs/public/images/agent/cloud-interface-with-artifacts.png differ
diff --git a/docs/public/images/agent/conversation-history-sidebar.png b/docs/public/images/agent/conversation-history-sidebar.png
new file mode 100644
index 000000000..728e372e3
Binary files /dev/null and b/docs/public/images/agent/conversation-history-sidebar.png differ
diff --git a/docs/public/images/agent/custom-assistant-message.png b/docs/public/images/agent/custom-assistant-message.png
new file mode 100644
index 000000000..5c56b4aac
Binary files /dev/null and b/docs/public/images/agent/custom-assistant-message.png differ
diff --git a/docs/public/images/agent/sidebar-custom-nav-items.png b/docs/public/images/agent/sidebar-custom-nav-items.png
new file mode 100644
index 000000000..b959e444e
Binary files /dev/null and b/docs/public/images/agent/sidebar-custom-nav-items.png differ
diff --git a/docs/public/images/agent/welcome-and-starters.png b/docs/public/images/agent/welcome-and-starters.png
new file mode 100644
index 000000000..bc4cc65a7
Binary files /dev/null and b/docs/public/images/agent/welcome-and-starters.png differ
diff --git a/docs/public/images/chat/architecture.svg b/docs/public/images/chat/architecture.svg
deleted file mode 100644
index ba7831732..000000000
--- a/docs/public/images/chat/architecture.svg
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
-
-
-
- Your Backend (any LLM)
-
-
- streams response
-
-
-
- Stream Adapter (parses provider format)
-
-
- normalized events
-
-
-
- ChatProvider (messages, threads, streaming)
-
-
- React context
-
-
-
- Copilot
-
-
- FullScreen
-
-
- BottomTray
-
diff --git a/docs/public/images/chat/layouts.svg b/docs/public/images/chat/layouts.svg
deleted file mode 100644
index e489c6f8e..000000000
--- a/docs/public/images/chat/layouts.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
- [ Layouts Preview ]
- Copilot - Full Screen - Bottom Tray
-
diff --git a/docs/public/videos/agent/agent-interface-hero.mp4 b/docs/public/videos/agent/agent-interface-hero.mp4
new file mode 100644
index 000000000..d151bbd0e
Binary files /dev/null and b/docs/public/videos/agent/agent-interface-hero.mp4 differ
diff --git a/docs/public/videos/agent/artifact-inline-to-side-panel.mp4 b/docs/public/videos/agent/artifact-inline-to-side-panel.mp4
new file mode 100644
index 000000000..07310434d
Binary files /dev/null and b/docs/public/videos/agent/artifact-inline-to-side-panel.mp4 differ
diff --git a/docs/public/videos/agent/generative-ui-chart-inline.mp4 b/docs/public/videos/agent/generative-ui-chart-inline.mp4
new file mode 100644
index 000000000..895bd76b1
Binary files /dev/null and b/docs/public/videos/agent/generative-ui-chart-inline.mp4 differ
diff --git a/docs/public/videos/agent/generative-ui-form-interaction.mp4 b/docs/public/videos/agent/generative-ui-form-interaction.mp4
new file mode 100644
index 000000000..4fd2ffbfa
Binary files /dev/null and b/docs/public/videos/agent/generative-ui-form-interaction.mp4 differ
diff --git a/docs/public/videos/agent/quickstart-inline-generative-ui.mp4 b/docs/public/videos/agent/quickstart-inline-generative-ui.mp4
new file mode 100644
index 000000000..ff75a1208
Binary files /dev/null and b/docs/public/videos/agent/quickstart-inline-generative-ui.mp4 differ
diff --git a/docs/public/videos/agent/tool-call-web-search.mp4 b/docs/public/videos/agent/tool-call-web-search.mp4
new file mode 100644
index 000000000..4fd4638e3
Binary files /dev/null and b/docs/public/videos/agent/tool-call-web-search.mp4 differ
diff --git a/examples/fastapi-backend/README.md b/examples/fastapi-backend/README.md
index 0288a639f..e974990a3 100644
--- a/examples/fastapi-backend/README.md
+++ b/examples/fastapi-backend/README.md
@@ -11,7 +11,7 @@ This is the first example in the repo using a non-Node.js backend — the same f
│ Vite + React │ POST │ FastAPI (Python) │
│ (port 5173) │ ──────► │ (port 8000) │
│ │ │ │
-│ • FullScreen UI │ │ • POST /api/chat │
+│ • AgentInterface UI │ │ • POST /api/chat │
│ • openAIReadable- │ NDJSON │ • OpenAI streaming │
│ StreamAdapter() │ ◄────── │ • AsyncOpenAI client │
└────────────────────────┘ └─────────────────────────┘
diff --git a/examples/fastapi-backend/frontend/package.json b/examples/fastapi-backend/frontend/package.json
index b59bfdcdd..9160dfafd 100644
--- a/examples/fastapi-backend/frontend/package.json
+++ b/examples/fastapi-backend/frontend/package.json
@@ -8,9 +8,9 @@
"preview": "vite preview"
},
"dependencies": {
- "@openuidev/react-headless": "latest",
- "@openuidev/react-lang": "latest",
- "@openuidev/react-ui": "latest",
+ "@openuidev/react-headless": "workspace:*",
+ "@openuidev/react-lang": "workspace:*",
+ "@openuidev/react-ui": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
diff --git a/examples/fastapi-backend/frontend/src/App.jsx b/examples/fastapi-backend/frontend/src/App.jsx
index 83503dff2..6e0328280 100644
--- a/examples/fastapi-backend/frontend/src/App.jsx
+++ b/examples/fastapi-backend/frontend/src/App.jsx
@@ -1,31 +1,40 @@
import "@openuidev/react-ui/components.css";
import "@openuidev/react-ui/styles/index.css";
-import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
+import {
+ AgentInterface,
+ openAIMessageFormat,
+ openAIReadableStreamAdapter,
+} from "@openuidev/react-ui";
import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-lib";
+import { useMemo } from "react";
const systemPrompt = openuiLibrary.prompt(openuiPromptOptions);
export default function App() {
+ // Storage is AgentInterface's built-in in-memory default (wiped on reload). The
+ // backend call is unchanged — only the chat surface moved from FullScreen to
+ // AgentInterface.
+ const llm = useMemo(
+ () => ({
+ send: ({ messages, signal }) =>
+ fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ systemPrompt,
+ messages: openAIMessageFormat.toApi(messages),
+ }),
+ signal,
+ }),
+ streamProtocol: openAIReadableStreamAdapter(),
+ }),
+ [],
+ );
+
return (
-
{
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- systemPrompt,
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIReadableStreamAdapter()}
- componentLibrary={openuiLibrary}
- agentName="OpenUI Chat"
- />
+
);
}
diff --git a/examples/form-generator/src/app/page.tsx b/examples/form-generator/src/app/page.tsx
index cc3cdba05..41ed70788 100644
--- a/examples/form-generator/src/app/page.tsx
+++ b/examples/form-generator/src/app/page.tsx
@@ -82,21 +82,16 @@ export default function Page() {
const assistantId = crypto.randomUUID();
+ const applyContent = (msg: Message) => {
+ draftContent = typeof msg.content === "string" ? msg.content : "";
+ setStreamingCode(draftContent);
+ };
+
await processStreamedMessage({
response,
adapter: openAIAdapter(),
- createMessage: (msg) => {
- draftContent = msg.content ?? "";
- setStreamingCode(draftContent);
- },
- updateMessage: (msg) => {
- draftContent = msg.content ?? "";
- setStreamingCode(draftContent);
- },
- deleteMessage: () => {
- draftContent = "";
- setStreamingCode("");
- },
+ createMessage: applyContent,
+ updateMessage: applyContent,
});
const assistantMsg: Message = {
diff --git a/examples/hands-on-table-chat/README.md b/examples/hands-on-table-chat/README.md
index 347c718e8..20c137b83 100644
--- a/examples/hands-on-table-chat/README.md
+++ b/examples/hands-on-table-chat/README.md
@@ -9,7 +9,7 @@ An AI-powered spreadsheet app that pairs a full-featured [Handsontable](https://
## Features
- **Live spreadsheet** — Handsontable grid with Excel-like editing, 386+ formula functions (via HyperFormula), context menus, column resizing, and CSV export
-- **AI chat panel** — OpenUI Copilot sidebar that understands the spreadsheet context and responds with rich UI (charts, tables, markdown)
+- **AI chat panel** — OpenUI `AgentInterface` chat surface (artifact rendering + thread history) that understands the spreadsheet context and responds with rich UI (charts, tables, markdown)
- **Bidirectional sync** — AI tool calls mutate the server-side table store, then push updates back to the grid via a `SpreadsheetTable` component
- **Formula-aware row operations** — Adding or deleting rows automatically shifts cell references in formulas (mirrors Excel/Sheets behavior)
- **Aggregate recalculation** — Total/Average/Sum/Count/Max/Min rows auto-update their formula ranges after structural changes
@@ -20,7 +20,7 @@ An AI-powered spreadsheet app that pairs a full-featured [Handsontable](https://
```
┌─────────────────────────────────┐ ┌──────────────────────────┐
│ Spreadsheet Panel │ │ Chat Panel │
-│ PersistentSpreadsheet.tsx │ │ OpenUI │
+│ PersistentSpreadsheet.tsx │ │ OpenUI │
│ (Handsontable + HyperFormula) │ │ spreadsheet-library.tsx │
└──────────────┬──────────────────┘ └────────────┬─────────────┘
│ │
@@ -128,7 +128,7 @@ hands-on-table-chat/
| [`handsontable`](https://handsontable.com/) | Excel-like data grid |
| [`@handsontable/react-wrapper`](https://www.npmjs.com/package/@handsontable/react-wrapper) | React bindings for Handsontable |
| [`hyperformula`](https://hyperformula.handsontable.com/) | Formula engine (386+ Excel-compatible functions) |
-| [`@openuidev/react-ui`](https://openui.com/docs) | OpenUI chat Copilot component |
+| [`@openuidev/react-ui`](https://openui.com/docs) | OpenUI `AgentInterface` chat component |
| [`@openuidev/react-headless`](https://openui.com/docs) | OpenUI adapter and message formatting |
| [`@openuidev/react-lang`](https://openui.com/docs) | OpenUI Lang component library DSL |
| [`openai`](https://www.npmjs.com/package/openai) | OpenAI SDK for chat completions with tool calling |
diff --git a/examples/hands-on-table-chat/src/app/page.tsx b/examples/hands-on-table-chat/src/app/page.tsx
index c6867597a..fc3f3ea24 100644
--- a/examples/hands-on-table-chat/src/app/page.tsx
+++ b/examples/hands-on-table-chat/src/app/page.tsx
@@ -1,21 +1,42 @@
"use client";
-import { openAIMessageFormat, openAIAdapter } from "@openuidev/react-headless";
-import { Copilot } from "@openuidev/react-ui";
import { spreadsheetLibrary } from "@/lib/spreadsheet-library";
-import { TableProvider, useTableContext } from "./TableContext";
-import { useState, useEffect, useCallback } from "react";
+import {
+ AgentInterface,
+ openAIAdapter,
+ openAIMessageFormat,
+ type ChatLLM,
+} from "@openuidev/react-ui";
import { MessageSquare, PanelRightClose } from "lucide-react";
import dynamic from "next/dynamic";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { TableProvider, useTableContext } from "./TableContext";
-const PersistentSpreadsheet = dynamic(
- () => import("./PersistentSpreadsheet"),
- { ssr: false }
-);
+const PersistentSpreadsheet = dynamic(() => import("./PersistentSpreadsheet"), { ssr: false });
function ChatPanel({ onClose }: { onClose: () => void }) {
const { threadId } = useTableContext();
+ // AgentInterface uses its built-in in-memory storage default (wiped on reload).
+ // The backend call is unchanged — only the chat surface moved from Copilot to
+ // AgentInterface.
+ const llm = useMemo(
+ () => ({
+ send: ({ messages, signal }) =>
+ fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: openAIMessageFormat.toApi(messages),
+ threadId,
+ }),
+ signal,
+ }),
+ streamProtocol: openAIAdapter(),
+ }),
+ [threadId],
+ );
+
return (
@@ -24,57 +45,43 @@ function ChatPanel({ onClose }: { onClose: () => void }) {
-
{
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- threadId,
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIAdapter()}
+
+ starterVariant="long"
+ starters={[
+ {
+ displayText: "Chart revenue by quarter",
+ prompt: "Show me a bar chart comparing Q1 through Q4 revenue for all products.",
+ },
+ {
+ displayText: "Add Vision Pro to the lineup",
+ prompt:
+ "Add a new product 'Vision Pro' in category 'Headsets' with Q1=8200, Q2=11500, Q3=14800, Q4=22000, Units Sold=450, Unit Price=3499, and a SUM formula for Annual Revenue.",
+ },
+ {
+ displayText: "Add a profit margin column",
+ prompt:
+ "Add a new column called 'Profit Margin' that calculates 35% of the Annual Revenue for each product.",
+ },
+ {
+ displayText: "Revenue breakdown by category",
+ prompt:
+ "Show me a pie chart of total annual revenue broken down by category (Laptops, Phones, Audio, etc.).",
+ },
+ {
+ displayText: "Compare Q1 vs Q4 growth",
+ prompt:
+ "Show me a table comparing Q1 and Q4 revenue for each product with the percentage growth.",
+ },
+ ]}
+ >
+
+
);
@@ -101,11 +108,7 @@ export default function Home() {
{chatOpen && }
{!chatOpen && (
- setChatOpen(true)}
- className="chat-fab"
- aria-label="Open chat"
- >
+ setChatOpen(true)} className="chat-fab" aria-label="Open chat">
AI Chat
diff --git a/examples/harnesses/pi-agent-harness/README.md b/examples/harnesses/pi-agent-harness/README.md
index 8e3bb842a..12a4a0522 100644
--- a/examples/harnesses/pi-agent-harness/README.md
+++ b/examples/harnesses/pi-agent-harness/README.md
@@ -14,7 +14,7 @@ launch — see **Security** below.
```
Browser (src/app/page.tsx)
- FullScreen chat ──POST /api/chat ({ systemPrompt, messages })──► route.ts (runtime=nodejs)
+ AgentInterface ──POST /api/chat ({ systemPrompt, messages })──► route.ts (runtime=nodejs)
+ openuiLibrary x-conversation-id: │
renderer ◄──NDJSON OpenAI chunks (delta.content = OpenUI Lang)─────────┤
▼
diff --git a/examples/harnesses/pi-agent-harness/src/app/page.tsx b/examples/harnesses/pi-agent-harness/src/app/page.tsx
index 7e3d49e01..e6ec006d6 100644
--- a/examples/harnesses/pi-agent-harness/src/app/page.tsx
+++ b/examples/harnesses/pi-agent-harness/src/app/page.tsx
@@ -2,47 +2,46 @@
import "@openuidev/react-ui/components.css";
import "@openuidev/react-ui/styles/index.css";
-import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
+import {
+ AgentInterface,
+ openAIMessageFormat,
+ openAIReadableStreamAdapter,
+ type ChatLLM,
+} from "@openuidev/react-ui";
import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-lib";
+import { useMemo } from "react";
const systemPrompt = openuiLibrary.prompt(openuiPromptOptions);
export default function Home() {
+ // AgentInterface uses its built-in in-memory storage default (wiped on reload).
+ // Each new thread gets a stable client-generated id, so the per-thread
+ // x-conversation-id maps to an isolated pi AgentSession. The backend call is
+ // unchanged; only the chat surface moved from FullScreen to AgentInterface.
+ const llm = useMemo(
+ () => ({
+ send: ({ threadId, messages, signal }) =>
+ fetch("/api/chat", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ // Map each chat thread to its own persistent pi AgentSession.
+ "x-conversation-id": threadId,
+ },
+ body: JSON.stringify({
+ systemPrompt,
+ messages: openAIMessageFormat.toApi(messages),
+ }),
+ signal,
+ }),
+ streamProtocol: openAIReadableStreamAdapter(),
+ }),
+ [],
+ );
+
return (
-
{
- const content = (firstMessage as { content?: unknown }).content;
- const title =
- typeof content === "string" && content.trim()
- ? content.trim().slice(0, 50)
- : "New chat";
- return { id: crypto.randomUUID(), title, createdAt: Date.now() };
- }}
- processMessage={async ({ threadId, messages, abortController }) => {
- return fetch("/api/chat", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- // Map each chat thread to its own persistent Pi AgentSession.
- "x-conversation-id": threadId,
- },
- body: JSON.stringify({
- systemPrompt,
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIReadableStreamAdapter()}
- componentLibrary={openuiLibrary}
- agentName="OpenUI Agent Harness"
- />
+
);
}
diff --git a/examples/harnesses/vercel-eve/next-env.d.ts b/examples/harnesses/vercel-eve/next-env.d.ts
index c4b7818fb..9edff1c7c 100644
--- a/examples/harnesses/vercel-eve/next-env.d.ts
+++ b/examples/harnesses/vercel-eve/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/examples/harnesses/vercel-eve/src/app/page.tsx b/examples/harnesses/vercel-eve/src/app/page.tsx
index aa56a1f70..df2df4eb7 100644
--- a/examples/harnesses/vercel-eve/src/app/page.tsx
+++ b/examples/harnesses/vercel-eve/src/app/page.tsx
@@ -1,45 +1,42 @@
"use client";
import { useTheme } from "@/hooks/use-system-theme";
-import { agUIAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
+import { AgentInterface } from "@openuidev/react-ui";
import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
import { useMemo } from "react";
import { createEveChatProps } from "../eve-chat";
export default function Page() {
const mode = useTheme();
- const chatProps = useMemo(() => createEveChatProps(), []);
+ const { llm, storage } = useMemo(() => createEveChatProps(), []);
return (
-
);
diff --git a/examples/harnesses/vercel-eve/src/eve-chat.ts b/examples/harnesses/vercel-eve/src/eve-chat.ts
index 31d3f7458..c5b8530a7 100644
--- a/examples/harnesses/vercel-eve/src/eve-chat.ts
+++ b/examples/harnesses/vercel-eve/src/eve-chat.ts
@@ -1,4 +1,9 @@
-import type { Message } from "@openuidev/react-headless";
+import {
+ agUIAdapter,
+ type ChatLLM,
+ type ChatStorage,
+ type Message,
+} from "@openuidev/react-headless";
import type { HandleMessageStreamEvent, SessionState } from "eve/client";
import { eveEventsToAGUI } from "./eve-stream";
import {
@@ -147,73 +152,66 @@ async function* runTurn(
/**
* Wires OpenUI's chat surface to an Eve agent over Eve's native session
- * protocol. `processMessage` delivers the latest user turn, maps Eve's events to
- * AG-UI, and persists the session cursor + transcript per thread. Pair with
- * `streamProtocol={agUIAdapter()}`.
+ * protocol. Returns the `llm` + `storage` adapters `` expects:
+ * `llm.send` delivers the latest user turn and maps Eve's events to AG-UI, while
+ * `storage.thread` persists the session cursor + transcript per thread.
*/
export function createEveChatProps(
storage: KVStorage = getClientStorage(),
store: ThreadStore = createThreadStore(storage),
-) {
- return {
- createThread: store.createThread,
- fetchThreadList: store.fetchThreadList,
- loadThread: store.loadThread,
- deleteThread: store.deleteThread,
- updateThread: store.updateThread,
- processMessage: async ({
- messages,
- threadId,
- abortController,
- }: {
- messages: Message[];
- threadId: string;
- abortController: AbortController;
- }): Promise => {
- store.saveMessages(threadId, messages);
-
- let nextSession = loadSession(storage, threadId);
- const turn = runTurn(
- nextSession,
- latestUserText(messages),
- abortController.signal,
- (state) => {
- nextSession = state;
- },
- );
-
- const encoder = new TextEncoder();
- let assistant = "";
-
- const body = new ReadableStream({
- async start(controller) {
- try {
- for await (const event of eveEventsToAGUI(turn)) {
- if (event.type === "TEXT_MESSAGE_CONTENT") {
- assistant += (event as { delta: string }).delta;
- }
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+): { llm: ChatLLM; storage: ChatStorage } {
+ const send: ChatLLM["send"] = async ({ messages, threadId, signal }): Promise => {
+ store.saveMessages(threadId, messages);
+
+ let nextSession = loadSession(storage, threadId);
+ const turn = runTurn(nextSession, latestUserText(messages), signal, (state) => {
+ nextSession = state;
+ });
+
+ const encoder = new TextEncoder();
+ let assistant = "";
+
+ const body = new ReadableStream({
+ async start(controller) {
+ try {
+ for await (const event of eveEventsToAGUI(turn)) {
+ if (event.type === "TEXT_MESSAGE_CONTENT") {
+ assistant += (event as { delta: string }).delta;
}
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- controller.enqueue(
- encoder.encode(`data: ${JSON.stringify({ type: "RUN_ERROR", message })}\n\n`),
- );
- } finally {
- saveSession(storage, threadId, nextSession);
- if (assistant) {
- store.saveMessages(threadId, [
- ...messages,
- { id: crypto.randomUUID(), role: "assistant", content: assistant } as Message,
- ]);
- }
- controller.enqueue(encoder.encode("data: [DONE]\n\n"));
- controller.close();
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify({ type: "RUN_ERROR", message })}\n\n`),
+ );
+ } finally {
+ saveSession(storage, threadId, nextSession);
+ if (assistant) {
+ store.saveMessages(threadId, [
+ ...messages,
+ { id: crypto.randomUUID(), role: "assistant", content: assistant } as Message,
+ ]);
}
- },
- });
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
+ controller.close();
+ }
+ },
+ });
- return new Response(body, { headers: { "content-type": "text/event-stream" } });
+ return new Response(body, { headers: { "content-type": "text/event-stream" } });
+ };
+
+ return {
+ llm: { send, streamProtocol: agUIAdapter() },
+ storage: {
+ thread: {
+ listThreads: () => store.fetchThreadList(),
+ createThread: (firstMessage) => store.createThread(firstMessage),
+ getMessages: (threadId) => store.loadThread(threadId),
+ updateThread: (thread) => store.updateThread(thread),
+ deleteThread: (id) => store.deleteThread(id),
+ },
},
};
}
diff --git a/examples/langgraph-chat/README.md b/examples/langgraph-chat/README.md
index fa7f7613a..46bee444a 100644
--- a/examples/langgraph-chat/README.md
+++ b/examples/langgraph-chat/README.md
@@ -20,7 +20,7 @@ browser ──fetch /api/chat──▶ Next.js route ──@langchain/langgraph-
| Piece | File | Role |
| --- | --- | --- |
-| Frontend | `src/app/page.tsx` | `` with `streamProtocol={langGraphAdapter()}`; converts messages with `langGraphMessageFormat.toApi`. |
+| Frontend | `src/app/page.tsx` | `` with `llm={{ send, streamProtocol: langGraphAdapter() }}`; converts messages with `langGraphMessageFormat.toApi`. |
| Proxy | `src/app/api/chat/route.ts` | Opens a stateless run on the LangGraph server and forwards its SSE. Keeps the API key + deployment URL server-side. |
| Graph | `src/agent/graph.ts` | Supervisor + specialist ReAct loops. Each specialist shares the generated OpenUI system prompt, so its output is OpenUI Lang. |
| Tools | `src/agent/tools.ts` | Mock `get_weather` / `get_stock_price` / `search_web` (no external keys needed). |
diff --git a/examples/langgraph-chat/src/app/page.tsx b/examples/langgraph-chat/src/app/page.tsx
index 6786d0d5e..41c2dfce7 100644
--- a/examples/langgraph-chat/src/app/page.tsx
+++ b/examples/langgraph-chat/src/app/page.tsx
@@ -2,50 +2,62 @@
import "@openuidev/react-ui/components.css";
import { useTheme } from "@/hooks/use-system-theme";
-import { langGraphAdapter, langGraphMessageFormat } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
+import {
+ AgentInterface,
+ langGraphAdapter,
+ langGraphMessageFormat,
+ type ChatLLM,
+} from "@openuidev/react-ui";
import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
+import { useMemo } from "react";
export default function Page() {
const mode = useTheme();
+ // Storage is optional; AgentInterface uses an in-memory default (wiped on
+ // reload). The backend call is unchanged — only the chat surface moved from
+ // FullScreen to AgentInterface.
+ const llm = useMemo(
+ () => ({
+ send: ({ messages, signal }) =>
+ fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ // Convert OpenUI messages to LangChain shape for the graph.
+ // The run is stateless: the full history is sent each turn.
+ messages: langGraphMessageFormat.toApi(messages),
+ }),
+ signal,
+ }),
+ streamProtocol: langGraphAdapter(),
+ }),
+ [],
+ );
+
return (
-
{
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- // Convert OpenUI messages to LangChain shape for the graph.
- // The run is stateless: the full history is sent each turn.
- messages: langGraphMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={langGraphAdapter()}
+
);
diff --git a/examples/mastra-chat/README.md b/examples/mastra-chat/README.md
index ce2f3c475..a0b49e879 100644
--- a/examples/mastra-chat/README.md
+++ b/examples/mastra-chat/README.md
@@ -4,7 +4,7 @@ An [OpenUI](https://openui.com) example showing how to wire a [Mastra](https://m
## What this demonstrates
-- Using `agUIAdapter()` as the `streamProtocol` on OpenUI's ` ` component
+- Using `agUIAdapter()` as the `streamProtocol` in the `llm` config of OpenUI's ` ` component
- A Mastra `Agent` with `createTool` tools (weather and stock price) running in a Next.js API route
- Streaming AG-UI protocol events from the server to the client via SSE
@@ -34,7 +34,7 @@ Open [http://localhost:3000](http://localhost:3000) to see the chat interface.
The server (`src/app/api/chat/route.ts`) wraps a Mastra `Agent` with `@ag-ui/mastra`'s `MastraAgent`, which emits AG-UI protocol events. These events are serialized as SSE and streamed to the client.
-The frontend (`src/app/page.tsx`) uses `agUIAdapter()` from `@openuidev/react-headless` as the `streamProtocol` for the ` ` component. The adapter parses the SSE stream into internal chat events that drive the UI.
+The frontend (`src/app/page.tsx`) renders OpenUI's ` ` (the artifact chat interface with thread history), passing it an `llm` whose `streamProtocol` is `agUIAdapter()` from `@openuidev/react-ui`. Storage is optional — `AgentInterface` defaults to in-memory storage (wiped on reload) — so no `storage` prop is needed. The adapter parses the SSE stream into internal chat events that drive the UI.
To add more tools, define them with `createTool` in `src/app/api/chat/route.ts` and pass them to the `Agent`.
diff --git a/examples/mastra-chat/src/app/page.tsx b/examples/mastra-chat/src/app/page.tsx
index a64566126..8571dbdfe 100644
--- a/examples/mastra-chat/src/app/page.tsx
+++ b/examples/mastra-chat/src/app/page.tsx
@@ -1,47 +1,54 @@
"use client";
import { useTheme } from "@/hooks/use-system-theme";
-import { agUIAdapter } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
+import { AgentInterface, agUIAdapter, type ChatLLM } from "@openuidev/react-ui";
import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
+import { useMemo } from "react";
export default function Page() {
const mode = useTheme();
+ // The backend call is unchanged — only the chat surface moved from FullScreen
+ // to AgentInterface. Storage is omitted, so AgentInterface uses its built-in
+ // in-memory default (wiped on reload).
+ const llm = useMemo(
+ () => ({
+ send: ({ messages, threadId, signal }) =>
+ fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ messages, threadId }),
+ signal,
+ }),
+ streamProtocol: agUIAdapter(),
+ }),
+ [],
+ );
+
return (
-
{
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ messages, threadId }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={agUIAdapter()}
+
);
diff --git a/examples/material-ui-chat/README.md b/examples/material-ui-chat/README.md
index 3a24912db..652e26e80 100644
--- a/examples/material-ui-chat/README.md
+++ b/examples/material-ui-chat/README.md
@@ -14,7 +14,14 @@ header = CardHeader("Q1 Sales")
tbl = Table([Col("Product"), Col("Revenue", "number")], [["Widget", 1200]])
```
-On the client, ` ` from `@openuidev/react-ui` manages conversation state, streaming, input, and rendering. It parses the incoming SSE stream with `openAIAdapter()` and renders each OpenUI Lang node using `muiChatLibrary` — the custom component library defined in `src/lib/mui-genui/`.
+On the client, ` ` from `@openuidev/react-ui` provides the artifact chat interface — thread history, streaming, input, and rendering. It requires an `llm` object whose `send()` calls the backend and whose `streamProtocol` parses the incoming SSE stream with `openAIAdapter()`, and a `componentLibrary` (`muiChatLibrary` — the custom component library defined in `src/lib/mui-genui/`) used to render each OpenUI Lang node. `storage` is optional — threads live in memory by default and reset on reload.
+
+```tsx
+
+```
## Architecture
@@ -22,14 +29,14 @@ On the client, ` ` from `@openuidev/react-ui` manages conversation
┌────────────────────────────────────┐ ┌────────────────────────────────────┐
│ Browser │ HTTP │ Next.js API Route │
│ │ ──────►│ │
-│ • manages UI │ │ • Loads system-prompt.txt │
+│ • manages UI │ │ • Loads system-prompt.txt │
│ • openAIAdapter() parses SSE │◄────── │ • Calls LLM with runTools │
│ • muiChatLibrary renders nodes │ SSE │ • Executes tools server-side │
│ • MUI ThemeProvider + CssBaseline │ │ • Streams response as SSE events │
└────────────────────────────────────┘ └────────────────────────────────────┘
```
-1. The user types a message. ` ` calls `processMessage`, which `POST`s to `/api/chat` with the conversation history formatted via `openAIMessageFormat.toApi()`.
+1. The user types a message. ` ` invokes the `llm.send()` callback, which `POST`s to `/api/chat` with the conversation history formatted via `openAIMessageFormat.toApi()`.
2. The API route reads `src/generated/system-prompt.txt`, instantiates an OpenAI client, and calls `runTools` — the OpenAI SDK's multi-step tool-execution loop.
3. Tool calls run server-side; results are fed back to the model automatically and emitted as SSE events.
4. The LLM streams a final OpenUI Lang response. The stream ends with `data: [DONE]`.
@@ -43,7 +50,7 @@ material-ui-chat/
│ ├── library.ts # Entry the OpenUI CLI reads to generate the prompt
│ ├── app/
│ │ ├── api/chat/route.ts # Streaming chat endpoint (OpenAI SDK + SSE)
-│ │ ├── page.tsx # Mounts + color-mode toggle
+│ │ ├── page.tsx # Mounts + color-mode toggle
│ │ ├── layout.tsx # Root layout with ColorModeProvider
│ │ └── globals.css # Minimal full-height reset
│ ├── hooks/
diff --git a/examples/material-ui-chat/next-env.d.ts b/examples/material-ui-chat/next-env.d.ts
index c4b7818fb..9edff1c7c 100644
--- a/examples/material-ui-chat/next-env.d.ts
+++ b/examples/material-ui-chat/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/examples/material-ui-chat/src/app/page.tsx b/examples/material-ui-chat/src/app/page.tsx
index 5c1e43730..264e23e90 100644
--- a/examples/material-ui-chat/src/app/page.tsx
+++ b/examples/material-ui-chat/src/app/page.tsx
@@ -5,8 +5,13 @@ import LightModeIcon from "@mui/icons-material/LightMode";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
-import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
+import {
+ AgentInterface,
+ openAIAdapter,
+ openAIMessageFormat,
+ type ChatLLM,
+} from "@openuidev/react-ui";
+import { useMemo } from "react";
import { useColorMode } from "@/hooks/use-system-theme";
import { muiChatLibrary } from "@/lib/mui-genui";
@@ -14,61 +19,67 @@ import { muiChatLibrary } from "@/lib/mui-genui";
export default function Page() {
const { mode, toggle } = useColorMode();
+ const llm = useMemo(
+ () => ({
+ send: ({ messages, signal }) =>
+ fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ messages: openAIMessageFormat.toApi(messages) }),
+ signal,
+ }),
+ streamProtocol: openAIAdapter(),
+ }),
+ [],
+ );
+
return (
- {mode === "dark" ? : }
+ {mode === "dark" ? (
+
+ ) : (
+
+ )}
- {
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIAdapter()}
+
);
diff --git a/examples/openui-artifact-demo/.dockerignore b/examples/openui-artifact-demo/.dockerignore
deleted file mode 100644
index 999ffc10a..000000000
--- a/examples/openui-artifact-demo/.dockerignore
+++ /dev/null
@@ -1,39 +0,0 @@
-# Node
-node_modules
-.pnpm-store
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-
-# Next.js
-.next
-out
-
-# Git
-.git
-.gitignore
-
-# Logs
-logs
-*.log
-
-# Env files
-.env
-.env.*
-!.env.example
-
-# OS files
-.DS_Store
-Thumbs.db
-
-# Build / cache
-dist
-build
-.turbo
-.cache
-coverage
-
-# Editor
-.vscode
-.idea
\ No newline at end of file
diff --git a/examples/openui-artifact-demo/Dockerfile b/examples/openui-artifact-demo/Dockerfile
deleted file mode 100644
index b3a36dfe1..000000000
--- a/examples/openui-artifact-demo/Dockerfile
+++ /dev/null
@@ -1,73 +0,0 @@
-# syntax=docker/dockerfile:1.7
-# --------------------------------------------------
-# Build stage
-# --------------------------------------------------
-FROM node:20-alpine AS builder
-
-WORKDIR /app
-
-RUN apk add --no-cache libc6-compat
-ARG PNPM_VERSION=9.12.0
-RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
-
-ENV NEXT_TELEMETRY_DISABLED=1
-
-COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.json ./
-
-COPY packages/openui-cli/package.json ./packages/openui-cli/
-COPY packages/react-ui/package.json ./packages/react-ui/
-COPY packages/react-headless/package.json ./packages/react-headless/
-COPY packages/react-lang/package.json ./packages/react-lang/
-COPY examples/openui-chat/package.json ./examples/openui-chat/
-
-RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
- pnpm install --frozen-lockfile --ignore-scripts
-
-COPY packages/openui-cli ./packages/openui-cli
-COPY packages/react-ui ./packages/react-ui
-COPY packages/react-headless ./packages/react-headless
-COPY packages/react-lang ./packages/react-lang
-COPY examples/openui-chat ./examples/openui-chat
-
-RUN pnpm --filter @openuidev/cli build
-RUN pnpm --filter @openuidev/react-ui build
-RUN pnpm --filter @openuidev/react-headless build
-RUN pnpm --filter @openuidev/react-lang build
-
-WORKDIR /app/examples/openui-chat
-RUN node /app/packages/openui-cli/dist/index.js generate src/library.ts --out src/generated/system-prompt.txt \
- && pnpm build
-
-
-
-# --------------------------------------------------
-# Runtime stage
-# --------------------------------------------------
-FROM node:20-alpine AS runner
-
-WORKDIR /app
-
-RUN apk add --no-cache libc6-compat
-
-ENV NODE_ENV=production
-ENV NEXT_TELEMETRY_DISABLED=1
-ENV PORT=3000 HOSTNAME=0.0.0.0
-
-RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
-USER nextjs
-
-# Copy full standalone output to avoid brittle partial-copy assumptions
-COPY --from=builder --chown=nextjs:nodejs /app/examples/openui-chat/.next/standalone ./
-
-# Static assets expected by Next at runtime
-COPY --from=builder --chown=nextjs:nodejs /app/examples/openui-chat/.next/static ./examples/openui-chat/.next/static
-
-# If your app has a public directory, include this line
-# COPY --from=builder --chown=nextjs:nodejs /app/examples/openui-chat/public ./examples/openui-chat/public
-
-EXPOSE 3000
-
-HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
- CMD node -e "fetch('http://127.0.0.1:3000').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
-
-CMD ["node", "examples/openui-chat/server.js"]
\ No newline at end of file
diff --git a/examples/openui-artifact-demo/README.md b/examples/openui-artifact-demo/README.md
deleted file mode 100644
index e2f54d200..000000000
--- a/examples/openui-artifact-demo/README.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# OpenUI Artifact Demo
-
-A demo application showcasing the OpenUI artifact system for displaying generated code in a resizable side panel.
-
-## Features
-
-- **Artifact Code Blocks**: AI-generated code appears as compact previews in chat
-- **Side Panel**: Click "View Code" to open the full code in a resizable artifact panel
-- **Syntax Highlighting**: Full Prism-based syntax highlighting in the artifact panel
-- **Multiple Artifacts**: Multiple code blocks per conversation, one active at a time
-- **Copy to Clipboard**: One-click code copying from the artifact panel
-
-## Getting Started
-
-```bash
-# Install dependencies (from repo root)
-pnpm install
-
-# Generate the system prompt
-pnpm --filter openui-artifact-demo generate:prompt
-
-# Start the development server
-pnpm --filter openui-artifact-demo dev
-```
-
-Set your OpenAI API key:
-```bash
-export OPENAI_API_KEY=your-key-here
-```
-
-## How It Works
-
-This example extends the standard OpenUI chat library with a custom `ArtifactCodeBlock` component that integrates with the OpenUI artifact system:
-
-1. User asks for code (e.g., "Build me a React login form")
-2. AI generates a response using `ArtifactCodeBlock` components
-3. Each code block shows an inline preview in the chat
-4. Clicking "View Code" opens the full code in the artifact side panel
-5. The panel is resizable and supports syntax highlighting + copy
-
-## Architecture
-
-- `src/components/ArtifactCodeBlock/` — Custom genui component with inline preview and artifact panel view
-- `src/library.ts` — Extended component library with ArtifactCodeBlock
-- `src/app/page.tsx` — Main page using FullScreen layout
-- `src/app/api/chat/route.ts` — API route for OpenAI streaming
diff --git a/examples/openui-artifact-demo/next.config.ts b/examples/openui-artifact-demo/next.config.ts
deleted file mode 100644
index 7e1f0ae21..000000000
--- a/examples/openui-artifact-demo/next.config.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { NextConfig } from "next";
-
-const nextConfig: NextConfig = {
- output: "standalone",
- turbopack: {},
-};
-
-export default nextConfig;
diff --git a/examples/openui-artifact-demo/package.json b/examples/openui-artifact-demo/package.json
deleted file mode 100644
index 2f5f0184c..000000000
--- a/examples/openui-artifact-demo/package.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "name": "openui-artifact-demo",
- "version": "0.1.0",
- "private": true,
- "scripts": {
- "generate:prompt": "pnpm --filter @openuidev/cli build && pnpm exec openui generate src/library.ts --out src/generated/system-prompt.txt",
- "dev": "pnpm generate:prompt && next dev",
- "build": "next build",
- "start": "next start",
- "lint": "eslint"
- },
- "dependencies": {
- "@openuidev/react-headless": "workspace:*",
- "@openuidev/react-lang": "workspace:*",
- "@openuidev/react-ui": "workspace:*",
- "lucide-react": "^0.575.0",
- "next": "16.2.6",
- "openai": "^6.22.0",
- "react": "19.2.3",
- "react-dom": "19.2.3",
- "react-syntax-highlighter": "^16.1.1",
- "zod": "^4.0.0"
- },
- "devDependencies": {
- "@openuidev/cli": "workspace:*",
- "@tailwindcss/postcss": "^4",
- "@types/node": "catalog:",
- "@types/react": "^19",
- "@types/react-dom": "^19",
- "@types/react-syntax-highlighter": "^15.5.13",
- "eslint": "^9",
- "eslint-config-next": "16.2.6",
- "tailwindcss": "^4",
- "typescript": "^5"
- }
-}
diff --git a/examples/openui-artifact-demo/src/app/api/chat/route.ts b/examples/openui-artifact-demo/src/app/api/chat/route.ts
deleted file mode 100644
index c0b88531c..000000000
--- a/examples/openui-artifact-demo/src/app/api/chat/route.ts
+++ /dev/null
@@ -1,274 +0,0 @@
-import { readFileSync } from "fs";
-import { NextRequest } from "next/server";
-import OpenAI from "openai";
-import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs";
-import { join } from "path";
-
-const systemPrompt = readFileSync(join(process.cwd(), "src/generated/system-prompt.txt"), "utf-8");
-
-// ── Tool implementations ──
-
-function getWeather({ location }: { location: string }): Promise {
- return new Promise((resolve) => {
- setTimeout(() => {
- const knownTemps: Record = {
- tokyo: 22, "san francisco": 18, london: 14, "new york": 25,
- paris: 19, sydney: 27, mumbai: 33, berlin: 16,
- };
- const conditions = ["Sunny", "Partly Cloudy", "Cloudy", "Light Rain", "Clear Skies"];
- const temp = knownTemps[location.toLowerCase()] ?? Math.floor(Math.random() * 30 + 5);
- const condition = conditions[Math.floor(Math.random() * conditions.length)];
- resolve(JSON.stringify({
- location, temperature_celsius: temp,
- temperature_fahrenheit: Math.round(temp * 1.8 + 32),
- condition,
- humidity_percent: Math.floor(Math.random() * 40 + 40),
- wind_speed_kmh: Math.floor(Math.random() * 25 + 5),
- forecast: [
- { day: "Tomorrow", high: temp + 2, low: temp - 4, condition: "Partly Cloudy" },
- { day: "Day After", high: temp + 1, low: temp - 3, condition: "Sunny" },
- ],
- }));
- }, 800);
- });
-}
-
-function getStockPrice({ symbol }: { symbol: string }): Promise {
- return new Promise((resolve) => {
- setTimeout(() => {
- const s = symbol.toUpperCase();
- const knownPrices: Record = {
- AAPL: 189.84, GOOGL: 141.8, TSLA: 248.42, MSFT: 378.91,
- AMZN: 178.25, NVDA: 875.28, META: 485.58,
- };
- const price = knownPrices[s] ?? Math.floor(Math.random() * 500 + 20);
- const change = parseFloat((Math.random() * 8 - 4).toFixed(2));
- resolve(JSON.stringify({
- symbol: s,
- price: parseFloat((price + change).toFixed(2)),
- change, change_percent: parseFloat(((change / price) * 100).toFixed(2)),
- volume: `${(Math.random() * 50 + 10).toFixed(1)}M`,
- day_high: parseFloat((price + Math.abs(change) + 1.5).toFixed(2)),
- day_low: parseFloat((price - Math.abs(change) - 1.2).toFixed(2)),
- }));
- }, 600);
- });
-}
-
-function searchWeb({ query }: { query: string }): Promise {
- return new Promise((resolve) => {
- setTimeout(() => {
- resolve(JSON.stringify({
- query,
- results: [
- { title: `Top result for "${query}"`, snippet: `Comprehensive overview of ${query} with the latest information.` },
- { title: `${query} - Latest News`, snippet: `Recent developments and updates related to ${query}.` },
- { title: `Understanding ${query}`, snippet: `An in-depth guide explaining everything about ${query}.` },
- ],
- }));
- }, 1000);
- });
-}
-
-// ── Tool definitions ──
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const tools: any[] = [
- {
- type: "function",
- function: {
- name: "get_weather",
- description: "Get current weather for a location.",
- parameters: {
- type: "object",
- properties: { location: { type: "string", description: "City name" } },
- required: ["location"],
- },
- function: getWeather,
- parse: JSON.parse,
- },
- },
- {
- type: "function",
- function: {
- name: "get_stock_price",
- description: "Get stock price for a ticker symbol.",
- parameters: {
- type: "object",
- properties: { symbol: { type: "string", description: "Ticker symbol, e.g. AAPL" } },
- required: ["symbol"],
- },
- function: getStockPrice,
- parse: JSON.parse,
- },
- },
- {
- type: "function",
- function: {
- name: "search_web",
- description: "Search the web for information.",
- parameters: {
- type: "object",
- properties: { query: { type: "string", description: "Search query" } },
- required: ["query"],
- },
- function: searchWeb,
- parse: JSON.parse,
- },
- },
-];
-
-// ── SSE helpers ──
-
-function sseToolCallStart(
- encoder: TextEncoder,
- tc: { id: string; function: { name: string } },
- index: number,
-) {
- return encoder.encode(
- `data: ${JSON.stringify({
- id: `chatcmpl-tc-${tc.id}`,
- object: "chat.completion.chunk",
- choices: [{
- index: 0,
- delta: {
- tool_calls: [{ index, id: tc.id, type: "function", function: { name: tc.function.name, arguments: "" } }],
- },
- finish_reason: null,
- }],
- })}\n\n`,
- );
-}
-
-function sseToolCallArgs(
- encoder: TextEncoder,
- tc: { id: string; function: { arguments: string } },
- result: string,
- index: number,
-) {
- let enrichedArgs: string;
- try {
- enrichedArgs = JSON.stringify({ _request: JSON.parse(tc.function.arguments), _response: JSON.parse(result) });
- } catch {
- enrichedArgs = tc.function.arguments;
- }
- return encoder.encode(
- `data: ${JSON.stringify({
- id: `chatcmpl-tc-${tc.id}-args`,
- object: "chat.completion.chunk",
- choices: [{
- index: 0,
- delta: { tool_calls: [{ index, function: { arguments: enrichedArgs } }] },
- finish_reason: null,
- }],
- })}\n\n`,
- );
-}
-
-// ── Route handler ──
-
-export async function POST(req: NextRequest) {
- const { messages } = await req.json();
-
- const client = new OpenAI({
- apiKey: process.env.OPENAI_API_KEY,
- });
- const MODEL = process.env.OPENAI_MODEL || "gpt-5.5";
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const cleanMessages = (messages as any[])
- .filter((m) => m.role !== "tool")
- .map((m) => {
- if (m.role === "assistant" && m.tool_calls?.length) {
- // Strip tool_calls (runTools re-runs the agentic loop server-side)
- // but preserve content so prior replies remain in context.
- const { tool_calls: _tc, ...rest } = m; // eslint-disable-line @typescript-eslint/no-unused-vars
- return rest;
- }
- return m;
- });
-
- const chatMessages: ChatCompletionMessageParam[] = [
- { role: "system", content: systemPrompt },
- ...cleanMessages,
- ];
-
- const encoder = new TextEncoder();
- let controllerClosed = false;
-
- const readable = new ReadableStream({
- start(controller) {
- const enqueue = (data: Uint8Array) => {
- if (controllerClosed) return;
- try { controller.enqueue(data); } catch { /* already closed */ }
- };
- const close = () => {
- if (controllerClosed) return;
- controllerClosed = true;
- try { controller.close(); } catch { /* already closed */ }
- };
-
- const pendingCalls: Array<{ id: string; name: string; arguments: string }> = [];
- let callIdx = 0;
- let resultIdx = 0;
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const runner = (client.chat.completions as any).runTools({
- model: MODEL,
- messages: chatMessages,
- tools,
- stream: true
- });
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- runner.on("functionToolCall", (fc: any) => {
- const id = `tc-${callIdx}`;
- pendingCalls.push({ id, name: fc.name, arguments: fc.arguments });
- enqueue(sseToolCallStart(encoder, { id, function: { name: fc.name } }, callIdx));
- callIdx++;
- });
-
- runner.on("functionToolCallResult", (result: string) => {
- const tc = pendingCalls[resultIdx];
- if (tc) {
- enqueue(sseToolCallArgs(encoder, { id: tc.id, function: { arguments: tc.arguments } }, result, resultIdx));
- }
- resultIdx++;
- });
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- runner.on("chunk", (chunk: any) => {
- const choice = chunk.choices?.[0];
- const delta = choice?.delta;
- if (!delta) return;
- if (delta.content) {
- enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
- }
- if (choice?.finish_reason === "stop") {
- enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
- }
- });
-
- runner.on("end", () => {
- enqueue(encoder.encode("data: [DONE]\n\n"));
- close();
- });
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- runner.on("error", (err: any) => {
- const msg = err instanceof Error ? err.message : "Stream error";
- console.error("Chat route error:", msg);
- enqueue(encoder.encode(`data: ${JSON.stringify({ error: msg })}\n\n`));
- close();
- });
- },
- });
-
- return new Response(readable, {
- headers: {
- "Content-Type": "text/event-stream",
- "Cache-Control": "no-cache, no-transform",
- Connection: "keep-alive",
- },
- });
-}
diff --git a/examples/openui-artifact-demo/src/app/globals.css b/examples/openui-artifact-demo/src/app/globals.css
deleted file mode 100644
index d41e3795a..000000000
--- a/examples/openui-artifact-demo/src/app/globals.css
+++ /dev/null
@@ -1,3 +0,0 @@
-@import "tailwindcss";
-@import "@openuidev/react-ui/styles/index.css";
-
diff --git a/examples/openui-artifact-demo/src/app/page.tsx b/examples/openui-artifact-demo/src/app/page.tsx
deleted file mode 100644
index 648b5cdd9..000000000
--- a/examples/openui-artifact-demo/src/app/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-"use client";
-
-import { useTheme } from "@/hooks/use-system-theme";
-import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-import { artifactDemoLibrary } from "@/library";
-
-export default function Page() {
- const mode = useTheme();
-
- return (
-
- {
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIAdapter()}
- componentLibrary={artifactDemoLibrary}
- agentName="Artifact Demo"
- theme={{ mode }}
- conversationStarters={{
- variant: "short",
- options: [
- {
- displayText: "React login form",
- prompt:
- "Build me a React login form with email and password validation",
- },
- {
- displayText: "Python REST API",
- prompt:
- "Create a FastAPI REST API with CRUD endpoints for a todo app",
- },
- {
- displayText: "CSS animation",
- prompt:
- "Write a CSS animation for a bouncing loading indicator",
- },
- {
- displayText: "SQL schema",
- prompt:
- "Design a SQL schema for a blog with users, posts, and comments",
- },
- ],
- }}
- />
-
- );
-}
diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/ArtifactView.tsx b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/ArtifactView.tsx
deleted file mode 100644
index bd5d85b2a..000000000
--- a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/ArtifactView.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-"use client";
-
-import { useState, useCallback } from "react";
-import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
-import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
-import { Copy, CheckCheck } from "lucide-react";
-
-interface ArtifactViewProps {
- language: string;
- codeString: string;
- title: string;
-}
-
-export function ArtifactView({ language, codeString, title }: ArtifactViewProps) {
- const [copied, setCopied] = useState(false);
-
- const handleCopy = useCallback(async () => {
- try {
- await navigator.clipboard.writeText(codeString);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- } catch {
- // Fallback for environments where clipboard API is unavailable
- const textarea = document.createElement("textarea");
- textarea.value = codeString;
- textarea.style.position = "fixed";
- textarea.style.opacity = "0";
- document.body.appendChild(textarea);
- textarea.select();
- document.execCommand("copy");
- document.body.removeChild(textarea);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }
- }, [codeString]);
-
- return (
-
- {/* Toolbar */}
-
-
{title}
-
-
- {language}
-
-
- {copied ? (
- <>
-
- Copied
- >
- ) : (
- <>
-
- Copy
- >
- )}
-
-
-
-
- {/* Code */}
-
-
- {codeString}
-
-
-
- );
-}
diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/InlinePreview.tsx b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/InlinePreview.tsx
deleted file mode 100644
index b75b860e5..000000000
--- a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/InlinePreview.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-"use client";
-
-interface InlinePreviewProps {
- language: string;
- title: string;
- codeString: string;
- open: () => void;
- isActive: boolean;
-}
-
-export function InlinePreview({ language, title, codeString, open, isActive }: InlinePreviewProps) {
- const truncatedCode = codeString.split("\n").slice(0, 6).join("\n");
-
- return (
-
- {/* Header */}
-
-
- {/* Code preview */}
-
-
- {truncatedCode}
-
- {/* Gradient fade */}
-
-
-
- {/* Footer */}
-
- !isActive && open()}
- className={`text-sm font-medium transition-colors ${
- isActive
- ? "cursor-default text-emerald-400"
- : "cursor-pointer text-blue-400 hover:text-blue-300"
- }`}
- >
- {isActive ? "✓ Viewing" : "View Code →"}
-
-
-
- );
-}
diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx
deleted file mode 100644
index ecf780567..000000000
--- a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-"use client";
-
-import { defineComponent } from "@openuidev/react-lang";
-import { Artifact } from "@openuidev/react-ui";
-import { ArtifactView } from "./ArtifactView";
-import { InlinePreview } from "./InlinePreview";
-import { ArtifactCodeBlockSchema } from "./schema";
-
-export { ArtifactCodeBlockSchema } from "./schema";
-export type { ArtifactCodeBlockProps } from "./schema";
-
-export const ArtifactCodeBlock = defineComponent({
- name: "ArtifactCodeBlock",
- props: ArtifactCodeBlockSchema,
- description:
- "Code block that opens in the artifact side panel for full viewing with syntax highlighting",
- component: Artifact({
- title: (props) => props.title as string,
- preview: (props, { open, isActive }) => (
-
- ),
- panel: (props) => (
-
- ),
- }),
-});
diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts
deleted file mode 100644
index 02ce83ac7..000000000
--- a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { z } from "zod";
-
-export const ArtifactCodeBlockSchema = z.object({
- language: z.string(),
- title: z.string(),
- codeString: z.string(),
-});
-
-export type ArtifactCodeBlockProps = z.infer;
diff --git a/examples/openui-artifact-demo/src/generated/system-prompt.txt b/examples/openui-artifact-demo/src/generated/system-prompt.txt
deleted file mode 100644
index f7263f410..000000000
--- a/examples/openui-artifact-demo/src/generated/system-prompt.txt
+++ /dev/null
@@ -1,239 +0,0 @@
-You are an AI assistant that responds using openui-lang, a declarative UI language. Your ENTIRE response must be valid openui-lang code — no markdown, no explanations, just openui-lang.
-
-## Syntax Rules
-
-1. Each statement is on its own line: `identifier = Expression`
-2. `root` is the entry point — every program must define `root = Card(...)`
-3. Expressions are: strings ("..."), numbers, booleans (true/false), null, arrays ([...]), objects ({...}), or component calls TypeName(arg1, arg2, ...)
-4. Use references for readability: define `name = ...` on one line, then use `name` later
-5. EVERY variable (except root) MUST be referenced by at least one other variable. Unreferenced variables are silently dropped and will NOT render. Always include defined variables in their parent's children/items array.
-6. Arguments are POSITIONAL (order matters, not names). Write `Stack([children], "row", "l")` NOT `Stack([children], direction: "row", gap: "l")` — colon syntax is NOT supported and silently breaks
-7. Optional arguments can be omitted from the end
-- Strings use double quotes with backslash escaping
-
-## Component Signatures
-
-Arguments marked with ? are optional. Sub-components can be inline or referenced; prefer references for better streaming.
-Props typed `ActionExpression` accept an Action([@steps...]) expression. See the Action section for available steps (@ToAssistant, @OpenUrl).
-Props marked `$binding` accept a `$variable` reference for two-way binding.
-
-### Content
-CardHeader(title?: string, subtitle?: string) — Header with optional title and subtitle
-TextContent(text: string, size?: "small" | "default" | "large" | "small-heavy" | "large-heavy") — Text block. Supports markdown. Optional size: "small" | "default" | "large" | "small-heavy" | "large-heavy".
-MarkDownRenderer(textMarkdown: string, variant?: "clear" | "card" | "sunk") — Renders markdown text with optional container variant
-Callout(variant: "info" | "warning" | "error" | "success" | "neutral", title: string, description: string, visible?: $binding) — Callout banner. Optional visible is a reactive $boolean — auto-dismisses after 3s by setting $visible to false.
-TextCallout(variant?: "neutral" | "info" | "warning" | "success" | "danger", title?: string, description?: string) — Text callout with variant, title, and description
-Image(alt: string, src?: string) — Image with alt text and optional URL
-ImageBlock(src: string, alt?: string) — Image block with loading state
-ImageGallery(images: {src: string, alt?: string, details?: string}[]) — Gallery grid of images with modal preview
-CodeBlock(language: string, codeString: string) — Syntax-highlighted code block
-Separator(orientation?: "horizontal" | "vertical", decorative?: boolean) — Visual divider between content sections
-ArtifactCodeBlock(language: string, title: string, codeString: string) — Code block that opens in the artifact side panel for full viewing with syntax highlighting
-
-### Tables
-Table(columns: Col[]) — Data table — column-oriented. Each Col holds its own data array.
-Col(label: string, data: any, type?: "string" | "number" | "action") — Column definition — holds label + data array
-
-### Charts (2D)
-BarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Vertical bars; use for comparing values across categories with one or more series
-LineChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Lines over categories; use for trends and continuous data over time
-AreaChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Filled area under lines; use for cumulative totals or volume trends over time
-RadarChart(labels: string[], series: Series[]) — Spider/web chart; use for comparing multiple variables across one or more entities
-HorizontalBarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Horizontal bars; prefer when category labels are long or for ranked lists
-Series(category: string, values: number[]) — One data series
-
-### Charts (1D)
-PieChart(labels: string[], values: number[], variant?: "pie" | "donut") — Circular slices; use plucked arrays: PieChart(data.categories, data.values)
-RadialChart(labels: string[], values: number[]) — Radial bars; use plucked arrays: RadialChart(data.categories, data.values)
-SingleStackedBarChart(labels: string[], values: number[]) — Single horizontal stacked bar; use plucked arrays: SingleStackedBarChart(data.categories, data.values)
-Slice(category: string, value: number) — One slice with label and numeric value
-
-### Charts (Scatter)
-ScatterChart(datasets: ScatterSeries[], xLabel?: string, yLabel?: string) — X/Y scatter plot; use for correlations, distributions, and clustering
-ScatterSeries(name: string, points: Point[]) — Named dataset
-Point(x: number, y: number, z?: number) — Data point with numeric coordinates
-
-### Forms
-Form(name: string, buttons: Buttons, fields?: FormControl[]) — Form container with fields and explicit action buttons
-FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup, hint?: string) — Field with label, input component, and optional hint text
-Label(text: string) — Text label
-Input(name: string, placeholder?: string, type?: "text" | "email" | "password" | "number" | "url", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)
-TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)
-Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)
-SelectItem(value: string, label: string) — Option for Select
-DatePicker(name: string, mode?: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)
-Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], label?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) — Numeric slider input; supports continuous and discrete (stepped) variants
-CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding>)
-CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean)
-RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)
-RadioItem(label: string, description: string, value: string)
-SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk", value?: $binding>) — Group of switch toggles
-SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean) — Individual switch toggle
-- Define EACH FormControl as its own reference — do NOT inline all controls in one array.
-- NEVER nest Form inside Form.
-- Form requires explicit buttons. Always pass a Buttons(...) reference as the third Form argument.
-- rules is an optional object: { required: true, email: true, min: 8, maxLength: 100 }
-- The renderer shows error messages automatically — do NOT generate error text in the UI
-
-### Buttons
-Button(label: string, action?: ActionExpression, variant?: "primary" | "secondary" | "tertiary", type?: "normal" | "destructive", size?: "extra-small" | "small" | "medium" | "large") — Clickable button
-Buttons(buttons: Button[], direction?: "row" | "column") — Group of Button components. direction: "row" (default) | "column".
-
-### Lists & Follow-ups
-ListBlock(items: ListItem[], variant?: "number" | "image") — A list of items with number or image indicators. Each item can optionally have an action.
-ListItem(title: string, subtitle?: string, image?: {src: string, alt: string}, actionLabel?: string, action?: ActionExpression) — Item in a ListBlock — displays a title with an optional subtitle and image. When action is provided, the item becomes clickable.
-FollowUpBlock(items: FollowUpItem[]) — List of clickable follow-up suggestions placed at the end of a response
-FollowUpItem(text: string) — Clickable follow-up suggestion — when clicked, sends text as user message
-- Use ListBlock with ListItem references for numbered, clickable lists.
-- Use FollowUpBlock with FollowUpItem references at the end of a response to suggest next actions.
-- Clicking a ListItem or FollowUpItem sends its text to the LLM as a user message.
-- Example: list = ListBlock([item1, item2]) item1 = ListItem("Option A", "Details about A")
-
-### Sections
-SectionBlock(sections: SectionItem[], isFoldable?: boolean) — Collapsible accordion sections. Auto-opens sections as they stream in. Use SectionItem for each section.
-SectionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | ListBlock | FollowUpBlock)[]) — Section with a label and collapsible content — used inside SectionBlock
-- SectionBlock renders collapsible accordion sections that auto-open as they stream.
-- Each section needs a unique `value` id, a `trigger` label, and a `content` array.
-- Example: sections = SectionBlock([s1, s2]) s1 = SectionItem("intro", "Introduction", [content1])
-- Set isFoldable=false to render sections as flat headers instead of accordion.
-
-### Layout
-Tabs(items: TabItem[]) — Tabbed container
-TabItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is tab label, content is array of components
-Accordion(items: AccordionItem[]) — Collapsible sections
-AccordionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is section title
-Steps(items: StepsItem[]) — Step-by-step guide
-StepsItem(title: string, details: string) — title and details text for one step
-Carousel(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[][], variant?: "card" | "sunk") — Horizontal scrollable carousel
-- Use Tabs to present alternative views — each TabItem has a value id, trigger label, and content array.
-- Carousel takes an array of slides, where each slide is an array of content: carousel = Carousel([[t1, img1], [t2, img2]])
-- IMPORTANT: Every slide in a Carousel must have the same structure — same component types in the same order.
-- For image carousels use: [[title, image, description, tags], ...] — every slide must follow this exact pattern.
-- Use real, publicly accessible image URLs (e.g. https://picsum.photos/seed/KEYWORD/800/500). Never hallucinate image URLs.
-
-### Data Display
-TagBlock(tags: string[]) — tags is an array of strings
-Tag(text: string, icon?: string, size?: "sm" | "md" | "lg", variant?: "neutral" | "info" | "success" | "warning" | "danger") — Styled tag/badge with optional icon and variant
-
-### Other
-Card(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | ListBlock | FollowUpBlock | SectionBlock | Tabs | Carousel)[]) — Vertical container for all content in a chat response. Children stack top to bottom automatically.
-
-## Action — Button Behavior
-
-Action([@steps...]) wires button clicks to operations. Steps are @-prefixed built-in actions. Steps execute in order.
-Buttons without an explicit Action prop automatically send their label to the assistant (equivalent to Action([@ToAssistant(label)])).
-
-Available steps:
-- @ToAssistant("message") — Send a message to the assistant (for conversational buttons like "Tell me more", "Explain this")
-- @OpenUrl("https://...") — Navigate to a URL
-
-Example — simple nav:
-```
-viewBtn = Button("View", Action([@OpenUrl("https://example.com")]))
-```
-
-- Action can be assigned to a variable or inlined: Button("Go", onSubmit) and Button("Go", Action([...])) both work
-
-## Hoisting & Streaming (CRITICAL)
-
-openui-lang supports hoisting: a reference can be used BEFORE it is defined. The parser resolves all references after the full input is parsed.
-
-During streaming, the output is re-parsed on every chunk. Undefined references are temporarily unresolved and appear once their definitions stream in. This creates a progressive top-down reveal — structure first, then data fills in.
-
-**Recommended statement order for optimal streaming:**
-1. `root = Card(...)` — UI shell appears immediately
-2. Component definitions — fill in as they stream
-3. Data values — leaf content last
-
-Always write the root = Card(...) statement first so the UI shell appears immediately, even before child data has streamed in.
-
-## Examples
-
-Example 1 — Table with follow-ups:
-
-root = Card([title, tbl, followUps])
-title = TextContent("Top Languages", "large-heavy")
-tbl = Table([Col("Language", langs), Col("Users (M)", users), Col("Year", years)])
-langs = ["Python", "JavaScript", "Java"]
-users = [15.7, 14.2, 12.1]
-years = [1991, 1995, 1995]
-followUps = FollowUpBlock([fu1, fu2])
-fu1 = FollowUpItem("Tell me more about Python")
-fu2 = FollowUpItem("Show me a JavaScript comparison")
-
-Example 2 — Clickable list:
-
-root = Card([title, list])
-title = TextContent("Choose a topic", "large-heavy")
-list = ListBlock([item1, item2, item3])
-item1 = ListItem("Getting started", "New to the platform? Start here.")
-item2 = ListItem("Advanced features", "Deep dives into powerful capabilities.")
-item3 = ListItem("Troubleshooting", "Common issues and how to fix them.")
-
-Example 3 — Image carousel with consistent slides + follow-ups:
-
-root = Card([header, carousel, followups])
-header = CardHeader("Featured Destinations", "Discover highlights and best time to visit")
-carousel = Carousel([[t1, img1, d1, tags1], [t2, img2, d2, tags2], [t3, img3, d3, tags3]], "card")
-t1 = TextContent("Paris, France", "large-heavy")
-img1 = ImageBlock("https://picsum.photos/seed/paris/800/500", "Eiffel Tower at night")
-d1 = TextContent("City of light — best Apr–Jun and Sep–Oct.", "default")
-tags1 = TagBlock(["Landmark", "City Break", "Culture"])
-t2 = TextContent("Kyoto, Japan", "large-heavy")
-img2 = ImageBlock("https://picsum.photos/seed/kyoto/800/500", "Bamboo grove in Arashiyama")
-d2 = TextContent("Temples and bamboo groves — best Mar–Apr and Nov.", "default")
-tags2 = TagBlock(["Temples", "Autumn", "Culture"])
-t3 = TextContent("Machu Picchu, Peru", "large-heavy")
-img3 = ImageBlock("https://picsum.photos/seed/machupicchu/800/500", "Inca citadel in the clouds")
-d3 = TextContent("High-altitude Inca citadel — best May–Sep.", "default")
-tags3 = TagBlock(["Andes", "Hike", "UNESCO"])
-followups = FollowUpBlock([fu1, fu2])
-fu1 = FollowUpItem("Show me only beach destinations")
-fu2 = FollowUpItem("Turn this into a comparison table")
-
-Example 4 — Form with validation:
-
-root = Card([title, form])
-title = TextContent("Contact Us", "large-heavy")
-form = Form("contact", btns, [nameField, emailField, msgField])
-nameField = FormControl("Name", Input("name", "Your name", "text", { required: true, minLength: 2 }))
-emailField = FormControl("Email", Input("email", "you@example.com", "email", { required: true, email: true }))
-msgField = FormControl("Message", TextArea("message", "Tell us more...", 4, { required: true, minLength: 10 }))
-btns = Buttons([Button("Submit", Action([@ToAssistant("Submit")]), "primary")])
-
-Example — Code generation with artifacts:
-root = Card([intro, code1, explanation, code2, followUps])
-intro = TextContent("Here's a React login form with validation:", "default")
-code1 = ArtifactCodeBlock("typescript", "LoginForm.tsx", "import React, { useState } from 'react';\n\nexport function LoginForm() {\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n\n return (\n \n );\n}")
-explanation = TextContent("And the validation helper:", "default")
-code2 = ArtifactCodeBlock("typescript", "validate.ts", "export function validateEmail(email: string): boolean {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email);\n}")
-followUps = FollowUpBlock([fu1, fu2])
-fu1 = FollowUpItem("Add password strength indicator")
-fu2 = FollowUpItem("Add form styling with Tailwind")
-
-## Important Rules
-- When asked about data, generate realistic/plausible data
-- Choose components that best represent the content (tables for comparisons, charts for trends, forms for input, etc.)
-
-## Final Verification
-Before finishing, walk your output and verify:
-1. root = Card(...) is the FIRST line (for optimal streaming).
-2. Every referenced name is defined. Every defined name (other than root) is reachable from root.
-
-- Every response is a single Card(children) — children stack vertically automatically. No layout params are needed on Card.
-- Card is the only layout container. Do NOT use Stack. Use Tabs to switch between sections, Carousel for horizontal scroll.
-- Use FollowUpBlock at the END of a Card to suggest what the user can do or ask next.
-- Use ListBlock when presenting a set of options or steps the user can click to select.
-- Use SectionBlock to group long responses into collapsible sections — good for reports, FAQs, and structured content.
-- Use SectionItem inside SectionBlock: each item needs a unique value id, a trigger (header label), and a content array.
-- Carousel takes an array of slides, where each slide is an array of content: carousel = Carousel([[t1, img1], [t2, img2]])
-- IMPORTANT: Every slide in a Carousel must use the same component structure in the same order — e.g. all slides: [title, image, description, tags].
-- For image carousels, always use real accessible URLs like https://picsum.photos/seed/KEYWORD/800/500. Never hallucinate or invent image URLs.
-- For forms, define one FormControl reference per field so controls can stream progressively.
-- For forms, always provide the second Form argument with Buttons(...) actions: Form(name, buttons, fields).
-- Never nest Form inside Form.
-- ALWAYS use ArtifactCodeBlock for ANY code output. NEVER use regular CodeBlock.
-- Set title to the filename (e.g. 'LoginForm.tsx', 'sort.py', 'schema.sql').
-- Set language to the correct syntax highlighting language (e.g. 'typescript', 'python', 'sql', 'css').
-- You can include multiple ArtifactCodeBlocks in one response — each with a unique title.
-- Surround code blocks with TextContent for explanations.
diff --git a/examples/openui-artifact-demo/src/library.ts b/examples/openui-artifact-demo/src/library.ts
deleted file mode 100644
index 0ab637d04..000000000
--- a/examples/openui-artifact-demo/src/library.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import type { ComponentGroup, PromptOptions } from "@openuidev/react-lang";
-import { createLibrary } from "@openuidev/react-lang";
-import {
- openuiChatComponentGroups,
- openuiChatLibrary,
- openuiChatPromptOptions,
-} from "@openuidev/react-ui/genui-lib";
-
-import { ArtifactCodeBlock } from "./components/ArtifactCodeBlock";
-
-// ── Component Groups — extend chat groups, add ArtifactCodeBlock to Content ──
-
-const artifactComponentGroups: ComponentGroup[] = openuiChatComponentGroups.map((group) => {
- if (group.name === "Content") {
- return {
- ...group,
- components: [...group.components, "ArtifactCodeBlock"],
- };
- }
- return group;
-});
-
-// ── Library — all chat components + ArtifactCodeBlock ──
-
-export const artifactDemoLibrary = createLibrary({
- root: "Card",
- componentGroups: artifactComponentGroups,
- components: [...Object.values(openuiChatLibrary.components), ArtifactCodeBlock],
-});
-
-// ── Prompt Options — extend chat rules with artifact-specific instructions ──
-
-export const artifactDemoPromptOptions: PromptOptions = {
- additionalRules: [
- ...(openuiChatPromptOptions.additionalRules ?? []),
- "ALWAYS use ArtifactCodeBlock for ANY code output. NEVER use regular CodeBlock.",
- "Set title to the filename (e.g. 'LoginForm.tsx', 'sort.py', 'schema.sql').",
- "Set language to the correct syntax highlighting language (e.g. 'typescript', 'python', 'sql', 'css').",
- "You can include multiple ArtifactCodeBlocks in one response — each with a unique title.",
- "Surround code blocks with TextContent for explanations.",
- ],
- examples: [
- ...(openuiChatPromptOptions.examples ?? []),
- `Example — Code generation with artifacts:
-root = Card([intro, code1, explanation, code2, followUps])
-intro = TextContent("Here's a React login form with validation:", "default")
-code1 = ArtifactCodeBlock("typescript", "LoginForm.tsx", "import React, { useState } from 'react';\\n\\nexport function LoginForm() {\\n const [email, setEmail] = useState('');\\n const [password, setPassword] = useState('');\\n\\n return (\\n \\n );\\n}")
-explanation = TextContent("And the validation helper:", "default")
-code2 = ArtifactCodeBlock("typescript", "validate.ts", "export function validateEmail(email: string): boolean {\\n return /^[^\\\\s@]+@[^\\\\s@]+\\\\.[^\\\\s@]+$/.test(email);\\n}")
-followUps = FollowUpBlock([fu1, fu2])
-fu1 = FollowUpItem("Add password strength indicator")
-fu2 = FollowUpItem("Add form styling with Tailwind")`,
- ],
-};
-
-// ── CLI exports — the generate:prompt script expects `library` and `promptOptions` ──
-
-export { artifactDemoLibrary as library, artifactDemoPromptOptions as promptOptions };
diff --git a/examples/openui-chat/src/app/page.tsx b/examples/openui-chat/src/app/page.tsx
index 2886da632..f3d7d3433 100644
--- a/examples/openui-chat/src/app/page.tsx
+++ b/examples/openui-chat/src/app/page.tsx
@@ -1,49 +1,59 @@
"use client";
import { useTheme } from "@/hooks/use-system-theme";
-import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
+import {
+ AgentInterface,
+ openAIAdapter,
+ openAIMessageFormat,
+ type ChatLLM,
+} from "@openuidev/react-ui";
import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
+import { useMemo } from "react";
export default function Page() {
const mode = useTheme();
+ // AgentInterface uses its built-in in-memory storage (wiped on reload). The
+ // backend call is unchanged — only the chat surface moved from FullScreen to
+ // AgentInterface.
+ const llm = useMemo(
+ () => ({
+ send: ({ messages, signal }) =>
+ fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ messages: openAIMessageFormat.toApi(messages) }),
+ signal,
+ }),
+ streamProtocol: openAIAdapter(),
+ }),
+ [],
+ );
+
return (
-
{
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIAdapter()}
+
);
diff --git a/examples/openui-cloud/.env.example b/examples/openui-cloud/.env.example
new file mode 100644
index 000000000..1228d86e7
--- /dev/null
+++ b/examples/openui-cloud/.env.example
@@ -0,0 +1,15 @@
+# openui-cloud env template — copy to .env.local and fill in your own values.
+# .env.local is gitignored; this template is committed. Restart `pnpm dev` after edits.
+
+# ── REQUIRED ──────────────────────────────────────────────────────────────────
+# Your OpenUI Cloud ORG MASTER KEY. Server-side only — never reaches the browser
+# (the routes field-pick the response). The frontend-token route 500s if unset.
+THESYS_API_KEY=sk-th-your-org-master-key
+
+# ── Optional (have sensible defaults) ─────────────────────────────────────────
+# Bare provider/model id for /api/chat generation (default: openai/gpt-5).
+OPENUI_MODEL=openai/gpt-5
+
+# End-user identity the frontend-token route stamps server-side (default: demo-user).
+# A real app derives this from its own auth, not an env var.
+DEMO_USER_ID=demo-user
diff --git a/examples/openui-artifact-demo/.gitignore b/examples/openui-cloud/.gitignore
similarity index 69%
rename from examples/openui-artifact-demo/.gitignore
rename to examples/openui-cloud/.gitignore
index 5ef6a5207..4e5c4a9a3 100644
--- a/examples/openui-artifact-demo/.gitignore
+++ b/examples/openui-cloud/.gitignore
@@ -32,10 +32,18 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.example
# vercel
.vercel
+# local thread index (created at runtime)
+/.data/
+
# typescript
*.tsbuildinfo
next-env.d.ts
+
+# vendored @openuidev/thesys tarball — obtain separately; never commit (the
+# package is distributed via the registry, not this repo).
+/vendor/
diff --git a/examples/openui-cloud/README.md b/examples/openui-cloud/README.md
new file mode 100644
index 000000000..66d00e370
--- /dev/null
+++ b/examples/openui-cloud/README.md
@@ -0,0 +1,84 @@
+# openui-cloud — OpenUI Cloud integration example
+
+A Next.js app showing how an external app integrates with OpenUI Cloud using its
+**two-plane** model:
+
+- **Generation plane (master key, server-side):** `/api/chat` forwards
+ `{ threadId, input }` to `POST /v1/embed/responses` with the org master key
+ (`conversation: threadId`, `store:true`, `stream:true`, `tools:[artifactTool()]`,
+ `instructions: createResponsesInstructions()`) and pipes the SSE stream back
+ unchanged. `/api/frontend-token` proxies `POST /v1/frontend-tokens` so the
+ browser gets a short-lived `fct_` token **without ever seeing the master key**.
+- **Read/edit plane (fct_, browser-direct):** the client page wires
+ ` `
+ against a `ChatStorage` from the **`useOpenuiCloudStorage()`** hook (browser →
+ `/v1/conversations` + `/v1/artifacts` via the `x-thesys-frontend-token` header,
+ single-flight refresh + 401 retry) and the presentation/report artifact
+ renderers (`artifactRenderers` / `artifactCategories` from `@openuidev/thesys`).
+
+## Local dependency wiring (do this first)
+
+`@openuidev/thesys` is **not published** — this app consumes it from a sibling
+**genui-sdk** checkout via a vendored tarball, and `@openuidev/thesys-server` via a
+vendored build. **Both `vendor/` artifacts are gitignored**, so after cloning you
+must produce them yourself.
+
+Prereq: `genui-sdk` cloned as a **sibling of `openui`** (so `../../../genui-sdk`
+resolves from this dir), on branch **`ap-server`**.
+
+```bash
+# 1. Build the SDK packages in genui-sdk (on ap-server).
+cd /path/to/genui-sdk
+git checkout ap-server && git pull && pnpm install
+pnpm --filter @openuidev/thesys build
+pnpm --filter @openuidev/thesys-server build
+
+# 2. Vendor both into openui-cloud.
+VENDOR=/path/to/openui/examples/openui-cloud/vendor
+( cd packages/c1 && pnpm pack --pack-destination "$VENDOR" ) # → openuidev-thesys-0.1.0.tgz
+mkdir -p "$VENDOR/c1-server" && cp packages/c1-server/dist/index.* "$VENDOR/c1-server/"
+
+# 3. Install this app (force — the tgz filename is stable, so pnpm caches it).
+cd /path/to/openui/examples/openui-cloud
+pnpm install --force
+```
+
+Re-run these whenever you change `c1` / `c1-server` in genui-sdk. `next.config.ts`
+aliases `@openuidev/thesys-server` → `vendor/c1-server/index.mjs` (Turbopack won't
+follow the cross-repo symlink) and stubs `lucide-react/dynamic`.
+
+## Setup (env)
+
+```bash
+cp .env.example .env.local # fill THESYS_API_KEY and point the base URLs at your API
+```
+
+Required env (see `.env.example`): `THESYS_API_KEY`, `OPENUI_CLOUD_BASE_URL`,
+`OPENUI_MODEL` (bare `provider/model`, e.g. `openai/gpt-5`), `DEMO_USER_ID`,
+`NEXT_PUBLIC_OPENUI_CLOUD_BASE_URL`.
+
+## Run
+
+```bash
+pnpm dev # http://localhost:3300
+```
+
+Point `OPENUI_CLOUD_BASE_URL` / `NEXT_PUBLIC_OPENUI_CLOUD_BASE_URL` at your OpenUI
+Cloud API origin.
+
+## Typecheck
+
+```bash
+pnpm exec tsc --noEmit
+```
+
+## SDK packages
+
+- `@openuidev/thesys-server` — the server SDK (`artifactTool`,
+ `createResponsesInstructions`) used by the `/api/chat` route.
+- `@openuidev/thesys` — the React SDK: `useOpenuiCloudStorage` (browser storage
+ hook), `artifactRenderers` / `artifactCategories`, `chatLibrary`, and the
+ `Presentation` / `Report` viewers, used by the client page. **Not published** —
+ vendored from genui-sdk (see "Local dependency wiring").
+- `@openuidev/react-headless` / `@openuidev/react-ui` — the chat UI runtime
+ (`AgentInterface`, storage/stream contracts, `defineArtifactRenderer`).
diff --git a/examples/openui-artifact-demo/eslint.config.mjs b/examples/openui-cloud/eslint.config.mjs
similarity index 100%
rename from examples/openui-artifact-demo/eslint.config.mjs
rename to examples/openui-cloud/eslint.config.mjs
diff --git a/examples/openui-cloud/next.config.ts b/examples/openui-cloud/next.config.ts
new file mode 100644
index 000000000..1f9da2fb1
--- /dev/null
+++ b/examples/openui-cloud/next.config.ts
@@ -0,0 +1,26 @@
+import type { NextConfig } from "next";
+import path from "path";
+
+const nextConfig: NextConfig = {
+ output: "standalone",
+ turbopack: {
+ // Pin the Turbopack root to the openui monorepo root so it follows the
+ // symlinked workspace deps (@openuidev/react-ui, react-lang, lang-core,
+ // react-headless) that @openuidev/thesys imports — these live in
+ // openui/packages/* and are otherwise treated as outside the inferred root.
+ root: path.resolve(process.cwd(), "../.."),
+ resolveAlias: {
+ // @openuidev/thesys's icon wrapper imports lucide-react dynamic-icon
+ // subpaths that the installed lucide-react no longer ships; alias them to
+ // an empty stub so the bundle compiles (dynamic icons fall back to defaults).
+ "lucide-react/dynamic": "./stubs/lucide-dynamic.mjs",
+ "lucide-react/dynamicIconImports.mjs": "./stubs/lucide-dynamic.mjs",
+ // @openuidev/thesys-server is linked cross-repo and Turbopack won't follow
+ // a symlink to a target outside the workspace root, so its built entry is
+ // vendored in-repo (vendor/c1-server) and aliased here.
+ "@openuidev/thesys-server": "./vendor/c1-server/index.mjs",
+ },
+ },
+};
+
+export default nextConfig;
diff --git a/examples/openui-cloud/package.json b/examples/openui-cloud/package.json
new file mode 100644
index 000000000..c9c419042
--- /dev/null
+++ b/examples/openui-cloud/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "openui-cloud",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev -p 3300",
+ "build": "next build",
+ "start": "next start -p 3300",
+ "lint": "eslint",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@floating-ui/react-dom": "2.1.3",
+ "@openuidev/lang-core": "workspace:*",
+ "@openuidev/react-headless": "workspace:*",
+ "@openuidev/react-lang": "workspace:*",
+ "@openuidev/react-ui": "workspace:*",
+ "@radix-ui/react-dialog": "1.1.15",
+ "@radix-ui/react-tooltip": "^1.2.0",
+ "@tanstack/react-table": "8.21.3",
+ "@tiptap/extension-placeholder": "2.27.2",
+ "@tiptap/react": "2.27.2",
+ "@tiptap/starter-kit": "2.27.2",
+ "clsx": "2.1.1",
+ "katex": "0.16.44",
+ "lodash": "4.17.21",
+ "lucide-react": "^0.575.0",
+ "mdast-util-find-and-replace": "3.0.2",
+ "mermaid": "11.15.0",
+ "next": "16.1.6",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "recharts": "2.15.4",
+ "rehype-katex": "7.0.1",
+ "remark-breaks": "4.0.0",
+ "remark-gfm": "4.0.1",
+ "remark-math": "6.0.0",
+ "tiny-invariant": "1.3.3",
+ "unist-util-visit": "5.1.0",
+ "zod": "^4.0.0",
+ "zustand": "catalog:"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "eslint": "^9",
+ "eslint-config-next": "16.1.6",
+ "openai": "^6.22.0",
+ "tailwindcss": "^4",
+ "typescript": "^5",
+ "vitest": "^4.1.0"
+ }
+}
diff --git a/examples/openui-artifact-demo/postcss.config.mjs b/examples/openui-cloud/postcss.config.mjs
similarity index 100%
rename from examples/openui-artifact-demo/postcss.config.mjs
rename to examples/openui-cloud/postcss.config.mjs
diff --git a/examples/openui-cloud/src/app/api/chat/route.ts b/examples/openui-cloud/src/app/api/chat/route.ts
new file mode 100644
index 000000000..48cf357f9
--- /dev/null
+++ b/examples/openui-cloud/src/app/api/chat/route.ts
@@ -0,0 +1,72 @@
+import { envOr, openuiCloudBaseUrl, requiredEnv } from "@/lib/env";
+import { artifactTool, createResponsesInstructions } from "@openuidev/thesys-server";
+import type { ResponseInputItem } from "openai/resources/responses/responses";
+
+/**
+ * Generation plane: browser → THIS route → OpenUI Cloud
+ * POST /v1/embed/responses with the org MASTER key (server env only).
+ * Reads/edits go browser → /v1/* with the fct_ token instead (see
+ * /api/frontend-token + the storage adapter). The artifact tool runs
+ * server-side, so this route is a pure pipe — no client-tool loop.
+ */
+export async function POST(req: Request) {
+ const { threadId, input } = (await req.json()) as {
+ threadId?: string;
+ input?: ResponseInputItem[];
+ };
+
+ // The conversation must already exist — the API replays history from it and
+ // stamps ownership on persist. The chat store creates the thread before the
+ // first send.
+ if (!threadId) {
+ return Response.json(
+ { error: { message: "threadId is required — create the conversation first" } },
+ { status: 400 },
+ );
+ }
+ if (!Array.isArray(input) || input.length === 0) {
+ return Response.json(
+ { error: { message: "input must be a non-empty ResponseInputItem[]" } },
+ { status: 400 },
+ );
+ }
+ console.log("artifactTool", artifactTool());
+ const upstream = await fetch(`${openuiCloudBaseUrl()}/v1/embed/responses`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${requiredEnv("THESYS_API_KEY")}`,
+ },
+ body: JSON.stringify({
+ // A bare provider/model id (versioned managed ids are mutually
+ // exclusive with the instructions config block). Configurable via
+ // OPENUI_MODEL (.env.local); defaults to openai/gpt-5.
+ model: envOr("OPENUI_MODEL", "openai/gpt-5"),
+ conversation: threadId,
+ input,
+ stream: true,
+ store: true,
+ tools: [artifactTool()],
+ instructions: createResponsesInstructions(),
+ }),
+ signal: req.signal, // propagate browser aborts (stop button / tab close)
+ });
+
+ if (!upstream.ok || !upstream.body) {
+ // Forward the upstream error body verbatim (OpenAI-shaped JSON).
+ const detail = await upstream.text().catch(() => "");
+ return new Response(detail || JSON.stringify({ error: { message: "upstream error" } }), {
+ status: upstream.status || 502,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ // Pipe the SSE byte stream through untouched.
+ return new Response(upstream.body, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ },
+ });
+}
diff --git a/examples/openui-cloud/src/app/api/frontend-token/route.ts b/examples/openui-cloud/src/app/api/frontend-token/route.ts
new file mode 100644
index 000000000..8f8e71918
--- /dev/null
+++ b/examples/openui-cloud/src/app/api/frontend-token/route.ts
@@ -0,0 +1,34 @@
+import { envOr, openuiCloudBaseUrl, requiredEnv } from "@/lib/env";
+
+/**
+ * Read-plane credential mint: proxies the OpenUI Cloud POST /v1/frontend-tokens
+ * (master-key plane) and returns ONLY { token, expires_at }.
+ *
+ * - The master key never reaches the browser (server env; the response is
+ * field-picked, never passed through).
+ * - user_id comes from server config — the browser must not choose its own
+ * identity.
+ */
+export async function POST() {
+ const upstream = await fetch(`${openuiCloudBaseUrl()}/v1/frontend-tokens`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${requiredEnv("THESYS_API_KEY")}`,
+ },
+ body: JSON.stringify({ user_id: envOr("DEMO_USER_ID", "demo-user") }),
+ });
+
+ if (!upstream.ok) {
+ // Never forward upstream auth-error bodies (they can embed key fragments).
+ console.error(
+ "[frontend-token] mint failed:",
+ upstream.status,
+ await upstream.text().catch(() => ""),
+ );
+ return Response.json({ error: { message: "token mint failed" } }, { status: 502 });
+ }
+
+ const { token, expires_at } = (await upstream.json()) as { token: string; expires_at: number };
+ return Response.json({ token, expires_at });
+}
diff --git a/examples/openui-cloud/src/app/globals.css b/examples/openui-cloud/src/app/globals.css
new file mode 100644
index 000000000..7070e07d3
--- /dev/null
+++ b/examples/openui-cloud/src/app/globals.css
@@ -0,0 +1,4 @@
+@import "tailwindcss";
+
+
+
\ No newline at end of file
diff --git a/examples/openui-artifact-demo/src/app/layout.tsx b/examples/openui-cloud/src/app/layout.tsx
similarity index 100%
rename from examples/openui-artifact-demo/src/app/layout.tsx
rename to examples/openui-cloud/src/app/layout.tsx
diff --git a/examples/openui-cloud/src/app/page.tsx b/examples/openui-cloud/src/app/page.tsx
new file mode 100644
index 000000000..dc818886a
--- /dev/null
+++ b/examples/openui-cloud/src/app/page.tsx
@@ -0,0 +1,88 @@
+"use client";
+import "@openuidev/react-ui/components.css";
+import "@openuidev/thesys/styles.css";
+
+import {
+ defineArtifactCategories,
+ openAIConversationMessageFormat,
+ openAIResponsesAdapter,
+ type ChatLLM,
+} from "@openuidev/react-headless";
+import { AgentInterface } from "@openuidev/react-ui";
+// chatLibrary, useOpenuiCloudStorage, and the artifact renderers all come from the
+// migrated SDK (@openuidev/thesys). Its artifact parser now reads the program from
+// the tool INPUT channel (args.artifact_content), so the rich preview renders live
+// during/after generation without a refresh.
+import { useTheme } from "@/hooks/use-system-theme";
+import {
+ chatLibrary,
+ presentationArtifactRenderer,
+ reportArtifactRenderer,
+ useOpenuiCloudStorage,
+} from "@openuidev/thesys";
+
+// Categories are consumer-owned (the SDK exports each renderer separately). One
+// category per genui artifact kind; `defineArtifactCategories` returns both the
+// deduped `artifactRenderers` and the `artifactCategories` (each `filter.type`
+// derived from the renderers' types). Presentation is listed first — it owns the
+// artifact tool names (the renderer registry is first-wins per toolName).
+const { artifactRenderers, artifactCategories } = defineArtifactCategories([
+ { name: "Presentations", renderers: [presentationArtifactRenderer] },
+ { name: "Reports", renderers: [reportArtifactRenderer] },
+]);
+
+const llm: ChatLLM = {
+ send: async ({ threadId, messages, signal }) => {
+ // The API replays full history via the conversation linkage — send only
+ // the latest message.
+ const latest = messages.slice(-1);
+ return fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ threadId, input: openAIConversationMessageFormat.toApi(latest) }),
+ signal,
+ });
+ },
+ streamProtocol: openAIResponsesAdapter(),
+};
+
+export default function Page() {
+ const mode = useTheme();
+ // useOpenuiCloudStorage: browser ChatStorage over /v1, fct_-authenticated. As a
+ // hook the storage + its fct_ token manager are created on mount (not at module
+ // load), so the token fetch follows this component's lifecycle.
+ const storage = useOpenuiCloudStorage({
+ // Backend mint proxy (POST → { token, expires_at }); the hook caches +
+ // refreshes it and injects x-thesys-frontend-token on every /v1 call.
+ token: "/api/frontend-token",
+ // Env-driven so a local stack can be targeted; defaults to prod when unset.
+ apiBaseUrl: process.env.NEXT_PUBLIC_OPENUI_CLOUD_BASE_URL,
+ features: { artifact: true },
+ });
+
+ return (
+
+ );
+}
diff --git a/examples/openui-artifact-demo/src/hooks/use-system-theme.tsx b/examples/openui-cloud/src/hooks/use-system-theme.tsx
similarity index 100%
rename from examples/openui-artifact-demo/src/hooks/use-system-theme.tsx
rename to examples/openui-cloud/src/hooks/use-system-theme.tsx
diff --git a/examples/openui-cloud/src/lib/env.ts b/examples/openui-cloud/src/lib/env.ts
new file mode 100644
index 000000000..c1878281c
--- /dev/null
+++ b/examples/openui-cloud/src/lib/env.ts
@@ -0,0 +1,21 @@
+// Env reads happen at REQUEST time (inside handlers), never at module scope:
+// tests can vi.stubEnv per-case and `next build` doesn't bake values in.
+
+export function requiredEnv(name: string): string {
+ const value = process.env[name];
+ if (!value) throw new Error(`Missing required env var: ${name}`);
+ return value;
+}
+
+export function envOr(name: string, fallback: string): string {
+ return process.env[name] || fallback;
+}
+
+/**
+ * OpenUI Cloud API origin (master-key plane: /v1/embed/responses, /v1/frontend-tokens).
+ * Read at request time (per this file's convention) and env-driven so a local stack can be
+ * targeted via `OPENUI_CLOUD_BASE_URL`; defaults to production.
+ */
+export function openuiCloudBaseUrl(): string {
+ return envOr("OPENUI_CLOUD_BASE_URL", "https://api.thesys.dev");
+}
diff --git a/examples/openui-cloud/src/lib/thesys/artifactStorage.ts b/examples/openui-cloud/src/lib/thesys/artifactStorage.ts
new file mode 100644
index 000000000..81c1a1b5e
--- /dev/null
+++ b/examples/openui-cloud/src/lib/thesys/artifactStorage.ts
@@ -0,0 +1,70 @@
+import type {
+ Artifact,
+ ArtifactListParams,
+ ArtifactStorage,
+ ArtifactSummary,
+} from "@openuidev/react-headless";
+import { cloudRequest, nextCursorOf, type CloudArtifact, type CloudListEnvelope } from "./wire";
+
+export interface CloudArtifactStorageOptions {
+ /** OpenUI Cloud API origin, e.g. "http://localhost:3102". */
+ baseUrl: string;
+ /** The token-injecting fetch from createFctFetch. */
+ fetch: typeof fetch;
+ /** Default page size when the caller passes no limit. */
+ pageLimit?: number;
+}
+
+function toSummary(artifact: CloudArtifact): ArtifactSummary {
+ return {
+ id: artifact.id,
+ title: artifact.name ?? artifact.id,
+ type: artifact.kind,
+ threadId: artifact.conversation_id,
+ updatedAt: (artifact.updated_at ?? artifact.created_at) * 1000,
+ };
+}
+
+
+export function cloudArtifactStorage({
+ baseUrl,
+ fetch: fetchImpl,
+ pageLimit = 100,
+}: CloudArtifactStorageOptions): ArtifactStorage {
+ const request = cloudRequest(fetchImpl, baseUrl);
+
+ return {
+ /** GET /v1/artifacts?[name=][kind=…]&limit[&after=]. Omitting the
+ * conversation scope lists across conversations, token-scoped to the user. */
+ async list(params?: ArtifactListParams) {
+ const query = new URLSearchParams();
+ if (params?.name !== undefined && params.name !== "") query.set("name", params.name);
+ for (const type of params?.type ?? []) query.append("kind", type);
+ if (params?.cursor !== undefined) query.set("after", params.cursor);
+ query.set("limit", String(params?.limit ?? pageLimit));
+ const res = await request(`/v1/artifacts?${query.toString()}`);
+ const envelope = (await res.json()) as CloudListEnvelope;
+ return { artifacts: envelope.data.map(toSummary), nextCursor: nextCursorOf(envelope) };
+ },
+
+ /** GET /v1/artifacts/:id → the stored openui-lang program (bare program;
+ * the renderer's parser sniffs the `root = …` root). */
+ async get(id: string): Promise {
+ const res = await request(`/v1/artifacts/${encodeURIComponent(id)}`);
+ const artifact = (await res.json()) as CloudArtifact;
+ return { ...toSummary(artifact), content: artifact.content };
+ },
+
+ /** POST /v1/artifacts/:id {content}. Send the edited inner program (a
+ * string); omit version to let the server bump it. */
+ async update(patch: { id: string; content: unknown }): Promise {
+ const content =
+ typeof patch.content === "string" ? patch.content : JSON.stringify(patch.content);
+ const res = await request(`/v1/artifacts/${encodeURIComponent(patch.id)}`, {
+ method: "POST",
+ body: JSON.stringify({ content }),
+ });
+ return toSummary((await res.json()) as CloudArtifact);
+ },
+ };
+}
diff --git a/examples/openui-cloud/src/lib/thesys/frontendTokenManager.ts b/examples/openui-cloud/src/lib/thesys/frontendTokenManager.ts
new file mode 100644
index 000000000..0f2055b54
--- /dev/null
+++ b/examples/openui-cloud/src/lib/thesys/frontendTokenManager.ts
@@ -0,0 +1,109 @@
+/**
+ * Frontend session-token (fct_) lifecycle for the browser plane.
+ *
+ * - The token rides ONLY the `x-thesys-frontend-token` header; `Authorization`
+ * on /v1/* always means the master key (server-side).
+ * - Minting happens on YOUR backend (here, the /api/frontend-token proxy),
+ * which calls the cloud mint endpoint with the master key and decides the
+ * end-user identity server-side. The browser sends no body and never names
+ * its own user.
+ * - Mint response: { token: 'fct_…', expires_at: }, TTL ~15 min.
+ *
+ * A fetch override (not static headers) is used so the token can refresh
+ * mid-session — the chat provider captures the storage object once at mount.
+ */
+
+export const FRONTEND_TOKEN_HEADER = "x-thesys-frontend-token";
+
+export interface MintFrontendTokenResponse {
+ token: string;
+ expires_at: number; // unix seconds
+}
+
+export interface FrontendTokenManagerOptions {
+ /** Your backend mint endpoint, e.g. "/api/frontend-token". */
+ mintUrl: string;
+ /** Override for tests / SSR. Defaults to globalThis.fetch. */
+ fetch?: typeof fetch;
+ /** Refresh this many seconds before expiry. Default 60. */
+ refreshSkewSeconds?: number;
+}
+
+export interface FrontendTokenManager {
+ /** A token valid for at least refreshSkewSeconds (single-flight mint). */
+ getToken(): Promise;
+ /** Drop the cached token. Pass the token that 401'd so a concurrent refresh
+ * is not discarded. */
+ invalidate(staleToken?: string): void;
+}
+
+export function createFrontendTokenManager({
+ mintUrl,
+ fetch: customFetch,
+ refreshSkewSeconds = 60,
+}: FrontendTokenManagerOptions): FrontendTokenManager {
+ const fetchImpl = customFetch ?? globalThis.fetch.bind(globalThis);
+
+ let token: string | null = null;
+ let expiresAt = 0; // unix seconds
+ let inflight: Promise | null = null;
+
+ const mint = async (): Promise => {
+ const res = await fetchImpl(mintUrl, { method: "POST" });
+ if (!res.ok) {
+ throw new Error(`frontend-token mint failed: ${res.status} ${res.statusText}`);
+ }
+ const body = (await res.json()) as MintFrontendTokenResponse;
+ token = body.token;
+ expiresAt = body.expires_at;
+ return body.token;
+ };
+
+ return {
+ async getToken(): Promise {
+ const nowSeconds = Date.now() / 1000;
+ if (token !== null && nowSeconds < expiresAt - refreshSkewSeconds) return token;
+ // Single-flight: callers during a refresh await the same mint.
+ if (inflight === null) {
+ inflight = mint().finally(() => {
+ inflight = null;
+ });
+ }
+ return inflight;
+ },
+
+ invalidate(staleToken?: string): void {
+ if (staleToken === undefined || staleToken === token) {
+ token = null;
+ expiresAt = 0;
+ }
+ },
+ };
+}
+
+/**
+ * Wrap a base fetch so every request carries a fresh token, with one reactive
+ * retry on 401. The request is re-sent with the same init — pass re-readable
+ * (string) bodies only.
+ */
+export function createFctFetch(tokens: FrontendTokenManager, baseFetch?: typeof fetch): typeof fetch {
+ const fetchImpl = baseFetch ?? globalThis.fetch.bind(globalThis);
+
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ const token = await tokens.getToken();
+ const headers = new Headers(init?.headers);
+ headers.set(FRONTEND_TOKEN_HEADER, token);
+
+ let res = await fetchImpl(input, { ...init, headers });
+
+ if (res.status === 401) {
+ tokens.invalidate(token);
+ const freshToken = await tokens.getToken();
+ const retryHeaders = new Headers(init?.headers);
+ retryHeaders.set(FRONTEND_TOKEN_HEADER, freshToken);
+ res = await fetchImpl(input, { ...init, headers: retryHeaders });
+ }
+
+ return res;
+ };
+}
diff --git a/examples/openui-cloud/src/lib/thesys/index.ts b/examples/openui-cloud/src/lib/thesys/index.ts
new file mode 100644
index 000000000..f93d3e38e
--- /dev/null
+++ b/examples/openui-cloud/src/lib/thesys/index.ts
@@ -0,0 +1,101 @@
+import type { ChatStorage } from "@openuidev/react-headless";
+import { cloudArtifactStorage } from "./artifactStorage";
+import { cloudThreadStorage } from "./threadStorage";
+import {
+ createFctFetch,
+ createFrontendTokenManager,
+ type FrontendTokenManager,
+} from "./frontendTokenManager";
+
+export { cloudArtifactStorage, type CloudArtifactStorageOptions } from "./artifactStorage";
+export { cloudItemsToMessages } from "./items";
+export { cloudThreadStorage, deriveTitle, type CloudThreadStorageOptions } from "./threadStorage";
+export {
+ FRONTEND_TOKEN_HEADER,
+ createFctFetch,
+ createFrontendTokenManager,
+ type FrontendTokenManager,
+ type FrontendTokenManagerOptions,
+ type MintFrontendTokenResponse,
+} from "./frontendTokenManager";
+export * from "./wire";
+
+/** Which storage surfaces openuiCloud wires. */
+export interface OpenuiCloudFeatures {
+ /** Stored-artifact reads + edits. Default true. */
+ artifact?: boolean;
+}
+
+export interface OpenuiCloudOptions {
+ /**
+ * The OpenUI Cloud API origin. Defaults to "https://api.thesys.dev" (the
+ * storage layer appends `/v1/...`). Set this to e.g. "http://localhost:3102"
+ * to run against a local stack. The browser calls this directly with the
+ * fct_ token — there is no same-origin proxy in between.
+ */
+ apiBaseUrl?: string;
+ /**
+ * Where the short-lived fct_ session token comes from — either a URL of your
+ * backend mint endpoint (POST → { token, expires_at }, cached + refreshed
+ * here) or a function returning a fresh token (you own caching). The token
+ * rides the `x-thesys-frontend-token` header on every /v1 call. The master
+ * key is minted server-side and never reaches the browser.
+ */
+ token: string | (() => Promise);
+ /** Which storage surfaces to wire. Omit to enable all. */
+ features?: OpenuiCloudFeatures;
+ /** fetch override (tests / SSR). Defaults to globalThis.fetch. */
+ fetch?: typeof fetch;
+ /** Refresh the cached token this many seconds before expiry (URL form). Default 60. */
+ refreshSkewSeconds?: number;
+}
+
+/**
+ * One-call browser wiring for OpenUI Cloud: a `ChatStorage` backed by the /v1
+ * API, authenticated per-request with an fct_ session token. Pass it straight
+ * to ` `.
+ *
+ * This is the READ/EDIT plane (browser → /v1/* with the fct_ token).
+ * Generation is the separate ChatLLM plane (browser → your backend →
+ * /v1/embed/responses with the master key).
+ */
+/** OpenUI Cloud API origin used when `apiBaseUrl` is omitted. The storage
+ * layer appends `/v1/...` to it. */
+const DEFAULT_API_BASE_URL = "https://api.thesys.dev";
+
+export function openuiCloud(options: OpenuiCloudOptions): ChatStorage {
+ const tokens = toTokenManager(options);
+ const fctFetch = createFctFetch(tokens, options.fetch);
+ const artifactOn = options.features?.artifact ?? true;
+ const baseUrl = options.apiBaseUrl ?? DEFAULT_API_BASE_URL;
+
+ const storage: ChatStorage = {
+ thread: cloudThreadStorage({ baseUrl, fetch: fctFetch }),
+ };
+ if (artifactOn) {
+ storage.artifact = cloudArtifactStorage({ baseUrl, fetch: fctFetch });
+ }
+ return storage;
+}
+
+/** Normalize the `token` option into a FrontendTokenManager. */
+function toTokenManager(options: OpenuiCloudOptions): FrontendTokenManager {
+ if (typeof options.token === "string") {
+ return createFrontendTokenManager({
+ mintUrl: options.token,
+ fetch: options.fetch,
+ refreshSkewSeconds: options.refreshSkewSeconds,
+ });
+ }
+ const provider = options.token;
+ let current: string | null = null;
+ return {
+ async getToken(): Promise {
+ current = await provider();
+ return current;
+ },
+ invalidate(staleToken?: string): void {
+ if (staleToken === undefined || staleToken === current) current = null;
+ },
+ };
+}
diff --git a/examples/openui-cloud/src/lib/thesys/items.ts b/examples/openui-cloud/src/lib/thesys/items.ts
new file mode 100644
index 000000000..26c914d10
--- /dev/null
+++ b/examples/openui-cloud/src/lib/thesys/items.ts
@@ -0,0 +1,65 @@
+import { openAIConversationMessageFormat, type Message } from "@openuidev/react-headless";
+import type { CloudConversationItem } from "./wire";
+
+/**
+ * Convert /v1 conversation items to AG-UI Message[]. Each item is normalized
+ * into the OpenAI ConversationItem shape that openAIConversationMessageFormat
+ * .fromApi expects, then delegated — the grouping logic (function_call →
+ * assistant toolCalls, function_call_output → ToolMessage) stays in the SDK.
+ *
+ * Normalizations:
+ * - message content: assistant outputs arrive as part arrays; user inputs
+ * arrive as a plain string → wrap strings as a single text part.
+ * - function_call / function_call_output: a malformed row (missing the
+ * top-level call_id/name/output) is skipped so it can't crash fromApi.
+ * - other item types are skipped.
+ */
+function normalizeItem(item: CloudConversationItem): Record | null {
+ switch (item.type) {
+ case "message": {
+ const content = item.content;
+ const parts = Array.isArray(content)
+ ? content
+ : [
+ {
+ type: item.role === "assistant" ? "output_text" : "input_text",
+ text: typeof content === "string" ? content : "",
+ },
+ ];
+ return {
+ id: item.id,
+ type: "message",
+ role: item.role ?? "user",
+ status: item.status ?? "completed",
+ content: parts,
+ };
+ }
+
+ case "function_call": {
+ if (typeof item.call_id !== "string" || typeof item.name !== "string") return null;
+ return {
+ id: item.id,
+ type: "function_call",
+ call_id: item.call_id,
+ name: item.name,
+ arguments:
+ typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {}),
+ };
+ }
+
+ case "function_call_output": {
+ if (typeof item.call_id !== "string" || item.output === undefined) return null;
+ return { id: item.id, type: "function_call_output", call_id: item.call_id, output: item.output };
+ }
+
+ default:
+ return null;
+ }
+}
+
+export function cloudItemsToMessages(items: CloudConversationItem[]): Message[] {
+ const normalized = items
+ .map(normalizeItem)
+ .filter((i): i is Record => i !== null);
+ return openAIConversationMessageFormat.fromApi(normalized);
+}
diff --git a/examples/openui-cloud/src/lib/thesys/threadStorage.ts b/examples/openui-cloud/src/lib/thesys/threadStorage.ts
new file mode 100644
index 000000000..a89af1664
--- /dev/null
+++ b/examples/openui-cloud/src/lib/thesys/threadStorage.ts
@@ -0,0 +1,109 @@
+import type { Message, Thread, ThreadStorage, UserMessage } from "@openuidev/react-headless";
+import { cloudItemsToMessages } from "./items";
+import {
+ cloudRequest,
+ nextCursorOf,
+ type CloudConversation,
+ type CloudConversationItem,
+ type CloudListEnvelope,
+} from "./wire";
+
+export interface CloudThreadStorageOptions {
+ /** OpenUI Cloud API origin, e.g. "http://localhost:3102". */
+ baseUrl: string;
+ /** The token-injecting fetch from createFctFetch. */
+ fetch: typeof fetch;
+ /** Page size for list/items calls. */
+ pageLimit?: number;
+}
+
+/** Hard stop for the items pagination loop. */
+const MAX_ITEM_PAGES = 50;
+
+function toThread(conversation: CloudConversation): Thread {
+ return {
+ id: conversation.id,
+ title: conversation.title ?? "New conversation",
+ createdAt: conversation.created_at * 1000, // unix seconds → ms
+ };
+}
+
+/** Client-side title from the first user message (the API does not auto-title). */
+export function deriveTitle(firstMessage: UserMessage): string {
+ const content = firstMessage.content;
+ let text = "";
+ if (typeof content === "string") {
+ text = content;
+ } else if (Array.isArray(content)) {
+ for (const part of content) {
+ if (part.type === "text" && typeof part.text === "string" && part.text.trim() !== "") {
+ text = part.text;
+ break;
+ }
+ }
+ }
+ text = text.trim();
+ return (text === "" ? "New conversation" : text).slice(0, 60);
+}
+
+export function cloudThreadStorage({
+ baseUrl,
+ fetch: fetchImpl,
+ pageLimit = 100,
+}: CloudThreadStorageOptions): ThreadStorage {
+ const request = cloudRequest(fetchImpl, baseUrl);
+
+ return {
+ /** GET /v1/conversations?limit[&after]. Newest-first. */
+ async listThreads(cursor?: string) {
+ const query = new URLSearchParams({ limit: String(pageLimit) });
+ if (cursor !== undefined) query.set("after", cursor);
+ const res = await request(`/v1/conversations?${query.toString()}`);
+ const envelope = (await res.json()) as CloudListEnvelope;
+ return { threads: envelope.data.map(toThread), nextCursor: nextCursorOf(envelope) };
+ },
+
+ /** POST /v1/conversations {title}. No messages and no user_id — the user is
+ * bound from the token; the first message arrives later on the generation
+ * plane (conversation linkage). */
+ async createThread(firstMessage: UserMessage): Promise {
+ const res = await request(`/v1/conversations`, {
+ method: "POST",
+ body: JSON.stringify({ title: deriveTitle(firstMessage) }),
+ });
+ return toThread((await res.json()) as CloudConversation);
+ },
+
+ /** GET /v1/conversations/:id/items?order=asc, paged, then mapped to Messages. */
+ async getMessages(threadId: string): Promise {
+ const items: CloudConversationItem[] = [];
+ let after: string | undefined;
+ for (let page = 0; page < MAX_ITEM_PAGES; page++) {
+ const query = new URLSearchParams({ order: "asc", limit: String(pageLimit) });
+ if (after !== undefined) query.set("after", after);
+ const res = await request(
+ `/v1/conversations/${encodeURIComponent(threadId)}/items?${query.toString()}`,
+ );
+ const envelope = (await res.json()) as CloudListEnvelope;
+ items.push(...envelope.data);
+ after = nextCursorOf(envelope);
+ if (after === undefined) break;
+ }
+ return cloudItemsToMessages(items);
+ },
+
+ /** POST /v1/conversations/:id {title}. */
+ async updateThread(thread: Thread): Promise {
+ const res = await request(`/v1/conversations/${encodeURIComponent(thread.id)}`, {
+ method: "POST",
+ body: JSON.stringify({ title: thread.title }),
+ });
+ return toThread((await res.json()) as CloudConversation);
+ },
+
+ /** DELETE /v1/conversations/:id (soft delete). */
+ async deleteThread(id: string): Promise {
+ await request(`/v1/conversations/${encodeURIComponent(id)}`, { method: "DELETE" });
+ },
+ };
+}
diff --git a/examples/openui-cloud/src/lib/thesys/wire.ts b/examples/openui-cloud/src/lib/thesys/wire.ts
new file mode 100644
index 000000000..c008bac6a
--- /dev/null
+++ b/examples/openui-cloud/src/lib/thesys/wire.ts
@@ -0,0 +1,82 @@
+/**
+ * Wire types for the OpenUI Cloud /v1 API, plus the shared list envelope,
+ * cursor rule, and request helper. Field-for-field mirrors of the API DTOs.
+ */
+
+/** A conversation. `created_at` is unix SECONDS. */
+export interface CloudConversation {
+ id: string;
+ object: "conversation";
+ created_at: number;
+ title?: string;
+ metadata?: Record;
+ user_id?: string;
+ app_id?: string;
+}
+
+/** A conversation item (full Responses item shape). */
+export interface CloudConversationItem {
+ id: string;
+ object: "conversation.item";
+ type: string; // message | function_call | function_call_output | ...
+ role?: string;
+ status?: string;
+ content?: unknown;
+ metadata?: Record;
+ created_at: number;
+ call_id?: string;
+ name?: string;
+ arguments?: string;
+ output?: unknown;
+}
+
+/** A stored artifact. `content` is the renderer-ready openui-lang program. */
+export interface CloudArtifact {
+ id: string;
+ object: "openui.artifact";
+ conversation_id: string;
+ kind: string; // 'slides' | 'report'
+ name?: string;
+ version?: string; // server bumps via String(Date.now()) when omitted
+ content: string;
+ created_at: number;
+ updated_at?: number;
+}
+
+/** List envelope shared by all paged endpoints. */
+export interface CloudListEnvelope {
+ object: "list";
+ data: T[];
+ has_more: boolean;
+ first_id?: string;
+ last_id?: string;
+}
+
+/** Forward cursor: pass `last_id` back as `?after=` when there's another page. */
+export function nextCursorOf(envelope: CloudListEnvelope): string | undefined {
+ return envelope.has_more && envelope.last_id ? envelope.last_id : undefined;
+}
+
+/**
+ * Request helper: prefix baseUrl, set JSON content-type only when sending a
+ * body, throw on non-2xx. `fetchImpl` is the token-injecting fetch — auth
+ * lives there, never here.
+ */
+export function cloudRequest(fetchImpl: typeof fetch, baseUrl: string) {
+ const base = baseUrl.replace(/\/+$/, "");
+ return async (path: string, init?: RequestInit): Promise => {
+ const res = await fetchImpl(`${base}${path}`, {
+ ...init,
+ headers: {
+ ...(init?.body ? { "Content-Type": "application/json" } : {}),
+ ...init?.headers,
+ },
+ });
+ if (!res.ok) {
+ throw new Error(
+ `OpenUI Cloud: ${init?.method ?? "GET"} ${path} failed: ${res.status} ${res.statusText}`,
+ );
+ }
+ return res;
+ };
+}
diff --git a/examples/openui-cloud/stubs/lucide-dynamic.mjs b/examples/openui-cloud/stubs/lucide-dynamic.mjs
new file mode 100644
index 000000000..b49cea677
--- /dev/null
+++ b/examples/openui-cloud/stubs/lucide-dynamic.mjs
@@ -0,0 +1,7 @@
+// Build shim for the example. The installed `lucide-react` version no longer
+// ships the dynamic-icon subpaths ('lucide-react/dynamic' and
+// 'lucide-react/dynamicIconImports.mjs') that @openuidev/thesys's icon wrapper
+// imports. next.config aliases those specifiers to this empty stub so the
+// bundle compiles; dynamic icons fall back to their defaults.
+export const dynamicIconImports = {};
+export default {};
diff --git a/examples/openui-artifact-demo/tsconfig.json b/examples/openui-cloud/tsconfig.json
similarity index 100%
rename from examples/openui-artifact-demo/tsconfig.json
rename to examples/openui-cloud/tsconfig.json
diff --git a/examples/react-email/package.json b/examples/react-email/package.json
index 9281e8e5d..b5d656cf9 100644
--- a/examples/react-email/package.json
+++ b/examples/react-email/package.json
@@ -11,9 +11,9 @@
},
"dependencies": {
"@openuidev/cli": "workspace:*",
- "@openuidev/react-email": "workspace:*",
- "@openuidev/react-headless": "workspace:*",
- "@openuidev/react-lang": "workspace:*",
+ "@openuidev/react-email": "0.2.4",
+ "@openuidev/react-headless": "0.8.2",
+ "@openuidev/react-lang": "0.2.6",
"@react-email/components": "^0.0.41",
"@react-email/render": "^1.0.6",
"lucide-react": "^0.562.0",
diff --git a/examples/react-email/src/components/composePage/starters.ts b/examples/react-email/src/components/composePage/starters.ts
index d94c94bf6..eb107d05e 100644
--- a/examples/react-email/src/components/composePage/starters.ts
+++ b/examples/react-email/src/components/composePage/starters.ts
@@ -23,7 +23,7 @@ Include these sections in order:
- (https://react.dev/favicon-32x32.png, "React", "React Components", "40+ production-ready components with defineComponent and Zod-powered type safety.")
- (https://openai.com/favicon.ico, "AI", "Any LLM Provider", "OpenAI, Anthropic, Gemini, Mistral — works with any OpenAI-compatible API.")
- (https://www.typescriptlang.org/favicon-32x32.png, "TypeScript", "OpenUI Lang", "A specialized format that LLMs generate natively — no JSON parsing, no prompt hacks.")
- - (https://vercel.com/favicon.ico, "Deploy", "Chat SDK Included", "Drop-in Copilot, FullScreen, and BottomTray layouts with streaming and persistence.")
+ - (https://vercel.com/favicon.ico, "Deploy", "Chat SDK Included", "Drop-in AgentInterface artifact UI with streaming and persistence.")
14. EmailDivider
15. EmailCodeBlock with this code:
"import { defineComponent } from '@openuidev/react-lang';\\nimport { z } from 'zod';\\n\\nexport const WeatherCard = defineComponent({\\n name: 'WeatherCard',\\n props: z.object({\\n city: z.string(),\\n temp: z.number(),\\n condition: z.string(),\\n }),\\n description: 'Shows current weather for a city',\\n component: ({ props }) => (\\n \\n
{props.city} \\n
{props.temp}° — {props.condition}
\\n
\\n ),\\n});"
diff --git a/examples/shadcn-chat/README.md b/examples/shadcn-chat/README.md
index a160db89d..4dc6d165a 100644
--- a/examples/shadcn-chat/README.md
+++ b/examples/shadcn-chat/README.md
@@ -30,7 +30,7 @@ Card([
])
```
-On the client, the ` ` component from `@openuidev/react-ui` handles everything — conversation state, streaming, input, and rendering. It parses the incoming SSE stream with `openAIAdapter()` and renders each OpenUI Lang node using `shadcnChatLibrary` — the custom 45-component library defined in `src/lib/shadcn-genui/`.
+On the client, the ` ` component from `@openuidev/react-ui` handles everything — thread history, conversation state, streaming, input, and rendering. You give it an `llm` describing how to call your backend and parse its stream, and a `componentLibrary`. It parses the incoming SSE stream with `openAIAdapter()` and renders each OpenUI Lang node using `shadcnChatLibrary` — the custom 45-component library defined in `src/lib/shadcn-genui/`.
---
@@ -40,7 +40,7 @@ On the client, the ` ` component from `@openuidev/react-ui` handles
┌────────────────────────────────────┐ ┌────────────────────────────────────┐
│ Browser │ HTTP │ Next.js API Route │
│ │ ──────►│ │
-│ • manages UI │ │ • Loads system-prompt.txt │
+│ • manages UI │ │ • Loads system-prompt.txt │
│ • openAIAdapter() parses SSE │◄────── │ • Calls LLM with runTools │
│ • shadcnChatLibrary renders nodes │ SSE │ • Executes tools server-side │
│ • Conversation starters included │ │ • Streams response as SSE events │
@@ -49,11 +49,11 @@ On the client, the ` ` component from `@openuidev/react-ui` handles
### Request / Response Flow
-1. User types a message. ` ` calls `processMessage`, which sends `POST /api/chat` with the conversation history formatted via `openAIMessageFormat.toApi()`.
+1. User types a message. ` ` calls `llm.send`, which sends `POST /api/chat` with the conversation history formatted via `openAIMessageFormat.toApi()`.
2. The API route reads `system-prompt.txt`, instantiates an OpenAI client, and calls `runTools` — the OpenAI SDK's built-in multi-step tool execution loop.
3. If the LLM calls a tool, `runTools` executes it server-side and feeds the result back into the model automatically, emitting SSE events for the tool call and result.
4. The LLM generates a final OpenUI Lang response. Text deltas are streamed as SSE `chunk` events. The stream ends with `data: [DONE]`.
-5. On the client, `openAIAdapter()` parses the SSE events and hands the accumulated text to ` `'s internal renderer.
+5. On the client, `openAIAdapter()` parses the SSE events and hands the accumulated text to ` `'s internal renderer.
6. The renderer passes the text to ` `, which parses the OpenUI Lang markup and renders each node as a shadcn/ui component in real time.
---
@@ -65,7 +65,7 @@ shadcn-chat/
├── src/
│ ├── app/
│ │ ├── api/chat/route.ts # Streaming chat endpoint (OpenAI SDK + SSE)
-│ │ ├── page.tsx # Single page — mounts
+│ │ ├── page.tsx # Single page — mounts
│ │ └── layout.tsx # Root layout with ThemeProvider
│ ├── components/ui/ # Base shadcn/ui primitives (accordion, card, table, etc.)
│ ├── hooks/
@@ -146,17 +146,36 @@ Messages are cleaned before sending to the API: `tool` role messages are strippe
### `src/app/page.tsx` — Frontend
-The entire chat interface is the ` ` component from `@openuidev/react-ui`. You configure it with three things:
+The entire chat interface is the ` ` component from `@openuidev/react-ui`. You configure it with two core props:
| Prop | Value | Purpose |
| ---- | ----- | ------- |
-| `processMessage` | `fetch("/api/chat", ...)` | How to call your backend |
-| `streamProtocol` | `openAIAdapter()` | How to parse the SSE stream |
+| `llm` | `{ send, streamProtocol }` | How to call your backend (`send`) and parse its stream (`streamProtocol`) |
| `componentLibrary` | `shadcnChatLibrary` | Which components to render OpenUI Lang nodes with |
-`openAIAdapter()` is imported from `@openuidev/react-headless`. It knows how to parse the OpenAI-style SSE format emitted by this route. `openAIMessageFormat.toApi()` converts the internal message objects into the format the OpenAI API expects.
+`storage` is optional — omit it for the built-in in-memory default (wiped on reload). Pass a `ChatStorage` adapter to persist the thread list.
+
+`llm.send` calls `fetch("/api/chat", ...)` with the conversation history formatted via `openAIMessageFormat.toApi()`, and `llm.streamProtocol` is set to `openAIAdapter()`:
+
+```tsx
+
+ fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ messages: openAIMessageFormat.toApi(messages) }),
+ signal,
+ }),
+ streamProtocol: openAIAdapter(),
+ }}
+ componentLibrary={shadcnChatLibrary}
+/>
+```
+
+`openAIAdapter()` and `openAIMessageFormat` are imported from `@openuidev/react-ui`. `openAIAdapter()` knows how to parse the OpenAI-style SSE format emitted by this route, and `openAIMessageFormat.toApi()` converts the internal message objects into the format the OpenAI API expects.
-The page also includes 7 built-in conversation starters to showcase the component library:
+The page also passes 7 built-in `starters` (each a `{ displayText, prompt }` pair) to showcase the component library:
| Starter | What it demonstrates |
| ------- | -------------------- |
diff --git a/examples/shadcn-chat/src/app/page.tsx b/examples/shadcn-chat/src/app/page.tsx
index d068a639e..51561636b 100644
--- a/examples/shadcn-chat/src/app/page.tsx
+++ b/examples/shadcn-chat/src/app/page.tsx
@@ -2,69 +2,78 @@
import { useTheme } from "@/hooks/use-system-theme";
import { shadcnChatLibrary } from "@/lib/shadcn-genui";
-import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
+import {
+ AgentInterface,
+ openAIAdapter,
+ openAIMessageFormat,
+ type ChatLLM,
+} from "@openuidev/react-ui";
+import { useMemo } from "react";
export default function Page() {
const mode = useTheme();
+ const llm = useMemo(
+ () => ({
+ send: ({ messages, signal }) =>
+ fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages: openAIMessageFormat.toApi(messages),
+ }),
+ signal,
+ }),
+ streamProtocol: openAIAdapter(),
+ }),
+ [],
+ );
+
return (
-
{
- return fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: openAIMessageFormat.toApi(messages),
- }),
- signal: abortController.signal,
- });
- }}
- streamProtocol={openAIAdapter()}
+
);
diff --git a/examples/supabase-chat/src/app/page.tsx b/examples/supabase-chat/src/app/page.tsx
index fd8332329..d51769e64 100644
--- a/examples/supabase-chat/src/app/page.tsx
+++ b/examples/supabase-chat/src/app/page.tsx
@@ -1,11 +1,15 @@
"use client";
-
-import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless";
-import { FullScreen } from "@openuidev/react-ui";
-import type { RealtimeChannel } from "@supabase/supabase-js";
-import { useEffect, useState } from "react";
import { createSupabaseBrowser } from "@/lib/supabase/browser";
+import {
+ AgentInterface,
+ openAIAdapter,
+ openAIMessageFormat,
+ restStorage,
+ type ChatLLM,
+} from "@openuidev/react-ui";
+import type { RealtimeChannel } from "@supabase/supabase-js";
+import { useEffect, useMemo, useState } from "react";
export default function Page() {
// Incrementing this key remounts ChatProvider, which re-runs fetchThreadList.
@@ -13,6 +17,31 @@ export default function Page() {
// another tab so the sidebar stays in sync without a full page reload.
const [threadListKey, setThreadListKey] = useState(0);
+ // Thread persistence stays server-backed (Supabase) via the same /api/threads
+ // REST contract the legacy `threadApiUrl` used — restStorage reproduces those
+ // conventions and keeps loadThread deserialization aligned with OpenAI format.
+ const storage = useMemo(
+ () => restStorage({ baseUrl: "/api/threads", messageFormat: openAIMessageFormat }),
+ [],
+ );
+ const llm = useMemo(
+ () => ({
+ send: ({ threadId, messages, signal }) =>
+ fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ // Convert from OpenUI's internal format to OpenAI chat format
+ messages: openAIMessageFormat.toApi(messages),
+ threadId,
+ }),
+ signal,
+ }),
+ streamProtocol: openAIAdapter(),
+ }),
+ [],
+ );
+
useEffect(() => {
const supabase = createSupabaseBrowser();
let channel: RealtimeChannel | undefined;
@@ -33,16 +62,12 @@ export default function Page() {
// including from another tab or device logged in with the same account.
channel = supabase
.channel("threads-realtime")
- .on(
- "postgres_changes",
- { event: "*", schema: "public", table: "threads" },
- () => {
- // Remount ChatProvider so the thread sidebar refreshes.
- // Note: remounting clears the current in-progress conversation.
- // For production, consider a more granular update strategy.
- setThreadListKey((k) => k + 1);
- },
- )
+ .on("postgres_changes", { event: "*", schema: "public", table: "threads" }, () => {
+ // Remount ChatProvider so the thread sidebar refreshes.
+ // Note: remounting clears the current in-progress conversation.
+ // For production, consider a more granular update strategy.
+ setThreadListKey((k) => k + 1);
+ })
.subscribe();
};
@@ -55,27 +80,7 @@ export default function Page() {
return (
-
- fetch("/api/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- // Convert from OpenUI's internal format to OpenAI chat format
- messages: openAIMessageFormat.toApi(messages),
- threadId,
- }),
- signal: abortController.signal,
- })
- }
- streamProtocol={openAIAdapter()}
- // Tell OpenUI that the thread API stores / returns messages in
- // OpenAI chat format so loadThread deserialization stays aligned.
- messageFormat={openAIMessageFormat}
- threadApiUrl="/api/threads"
- agentName="Supabase Chat"
- />
+
);
}
diff --git a/examples/supabase-chat/tsconfig.json b/examples/supabase-chat/tsconfig.json
index 93e220ad6..1894d9ffc 100644
--- a/examples/supabase-chat/tsconfig.json
+++ b/examples/supabase-chat/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -13,7 +17,26 @@
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
- "plugins": [{ "name": "next" }],
- "paths": { "@/*": ["./src/*"] }
- }
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts",
+ "**/*.ts",
+ "**/*.tsx"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}
diff --git a/packages/lang-core/package.json b/packages/lang-core/package.json
index 3e96ed168..c40b3f652 100644
--- a/packages/lang-core/package.json
+++ b/packages/lang-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@openuidev/lang-core",
- "version": "0.2.6",
+ "version": "0.2.1",
"description": "Framework-agnostic core for OpenUI Lang: parser, prompt generation, validation, and type definitions",
"license": "MIT",
"type": "module",
diff --git a/packages/lang-core/src/parser/parser.ts b/packages/lang-core/src/parser/parser.ts
index 9be78733c..32b443aca 100644
--- a/packages/lang-core/src/parser/parser.ts
+++ b/packages/lang-core/src/parser/parser.ts
@@ -436,18 +436,23 @@ export interface StreamParser {
}
export function createStreamParser(cat: ParamMap, rootName?: string): StreamParser {
- let buf = "";
- let completedEnd = 0;
+ let buf = ""; // raw accumulated input (kept for set() diffing)
+ // Preprocessed view of `buf` (fences + comments stripped, same as parse()'s
+ // preprocess). The completed-statement scan runs over THIS, never the raw
+ // buffer — so leading markdown prose / ```fences``` (e.g. an apostrophe in
+ // "You're …" before the program) can't corrupt statement-boundary detection.
+ let cleaned = "";
+ let completedEnd = 0; // watermark: how far into `cleaned` is already completed
const completedStmtMap = new Map();
let completedCount = 0;
let firstId = "";
function addStmt(text: string) {
- // Strip comments and skip fence markers
- const cleaned = stripComments(text).trim();
- if (!cleaned || /^```/.test(cleaned)) return;
- for (const s of split(tokenize(cleaned))) {
+ // `text` is sliced from `cleaned`, so it's already fence/comment-free.
+ const t = text.trim();
+ if (!t) return;
+ for (const s of split(tokenize(t))) {
const expr = parseExpression(s.tokens);
const stmt = classifyStatement(s, expr);
completedStmtMap.set(s.id, stmt);
@@ -456,6 +461,22 @@ export function createStreamParser(cat: ParamMap, rootName?: string): StreamPars
}
}
+ // Recompute `cleaned` from `buf`. If the already-completed prefix is no longer
+ // a prefix of the new cleaned text (e.g. an opening ```fence``` just appeared
+ // and shifted the stripped output), the watermark + cache are stale, so reset
+ // and re-scan. When the prefix is stable (the common streaming case) the cache
+ // is kept, so a partial trailing statement never blanks already-completed ones.
+ function refreshCleaned() {
+ const next = preprocess(buf);
+ if (!next.startsWith(cleaned.slice(0, completedEnd))) {
+ completedEnd = 0;
+ completedStmtMap.clear();
+ completedCount = 0;
+ firstId = "";
+ }
+ cleaned = next;
+ }
+
function scanNewCompleted(): number {
let depth = 0,
ternaryDepth = 0,
@@ -463,8 +484,8 @@ export function createStreamParser(cat: ParamMap, rootName?: string): StreamPars
esc = false;
let stmtStart = completedEnd;
- for (let i = completedEnd; i < buf.length; i++) {
- const c = buf[i];
+ for (let i = completedEnd; i < cleaned.length; i++) {
+ const c = cleaned[i];
if (esc) {
esc = false;
continue;
@@ -492,15 +513,21 @@ export function createStreamParser(cat: ParamMap, rootName?: string): StreamPars
// meaningful character is `?` or `:` — ternary continuation.
let peek = i + 1;
while (
- peek < buf.length &&
- (buf[peek] === " " || buf[peek] === "\t" || buf[peek] === "\r" || buf[peek] === "\n")
+ peek < cleaned.length &&
+ (cleaned[peek] === " " ||
+ cleaned[peek] === "\t" ||
+ cleaned[peek] === "\r" ||
+ cleaned[peek] === "\n")
)
peek++;
- if (peek < buf.length && (buf[peek] === "?" || (buf[peek] === ":" && ternaryDepth > 0))) {
+ if (
+ peek < cleaned.length &&
+ (cleaned[peek] === "?" || (cleaned[peek] === ":" && ternaryDepth > 0))
+ ) {
continue; // ternary continuation — don't split
}
// Depth-0 newline = end of a statement
- const t = buf.slice(stmtStart, i).trim();
+ const t = cleaned.slice(stmtStart, i).trim();
if (t) addStmt(t);
stmtStart = i + 1; // next statement begins after this newline
completedEnd = i + 1; // advance the "already processed" watermark
@@ -511,8 +538,9 @@ export function createStreamParser(cat: ParamMap, rootName?: string): StreamPars
}
function currentResult(): ParseResult {
+ refreshCleaned();
const pendingStart = scanNewCompleted();
- const pendingText = buf.slice(pendingStart).trim();
+ const pendingText = cleaned.slice(pendingStart).trim();
// No pending text — all statements are complete
if (!pendingText) {
@@ -528,22 +556,9 @@ export function createStreamParser(cat: ParamMap, rootName?: string): StreamPars
);
}
- // Apply same cleanup as parse() — strip fences, comments, whitespace
- const cleaned = stripComments(stripFences(pendingText)).trim();
- if (!cleaned) {
- if (completedCount === 0) return emptyResult();
- return buildResult(
- completedStmtMap,
- [...completedStmtMap.values()],
- firstId,
- false,
- completedCount,
- cat,
- rootName,
- );
- }
- // Autoclose the incomplete last statement so it's syntactically valid
- const { text: closed, wasIncomplete } = autoClose(cleaned);
+ // `cleaned` is already preprocessed (fences + comments stripped); just
+ // autoclose the incomplete trailing statement so it's syntactically valid.
+ const { text: closed, wasIncomplete } = autoClose(pendingText);
const stmts = split(tokenize(closed));
if (!stmts.length) {
@@ -587,6 +602,7 @@ export function createStreamParser(cat: ParamMap, rootName?: string): StreamPars
function reset() {
buf = "";
+ cleaned = "";
completedEnd = 0;
completedStmtMap.clear();
completedCount = 0;
diff --git a/packages/openui-cli/package.json b/packages/openui-cli/package.json
index eb35fd1a3..12be5876f 100644
--- a/packages/openui-cli/package.json
+++ b/packages/openui-cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@openuidev/cli",
- "version": "0.0.8",
+ "version": "0.0.9",
"description": "CLI for OpenUI — scaffold generative UI chat apps and generate LLM system prompts from component libraries",
"bin": {
"openui": "dist/index.js"
diff --git a/packages/openui-cli/src/templates/openui-chat/package.json b/packages/openui-cli/src/templates/openui-chat/package.json
index d043fd702..9cbc2a28c 100644
--- a/packages/openui-cli/src/templates/openui-chat/package.json
+++ b/packages/openui-cli/src/templates/openui-chat/package.json
@@ -1,6 +1,6 @@
{
"name": "openui-chat",
- "version": "0.1.0",
+ "version": "0.1.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -9,9 +9,9 @@
"lint": "eslint"
},
"dependencies": {
- "@openuidev/react-ui": "latest",
- "@openuidev/react-headless": "latest",
- "@openuidev/react-lang": "latest",
+ "@openuidev/react-ui": "0.11.9",
+ "@openuidev/react-headless": "0.8.2",
+ "@openuidev/react-lang": "0.2.6",
"next": "16.1.6",
"openai": "^6.22.0",
"react": "19.2.3",
diff --git a/packages/react-headless/package.json b/packages/react-headless/package.json
index bb5a39e9a..742f249a6 100644
--- a/packages/react-headless/package.json
+++ b/packages/react-headless/package.json
@@ -1,6 +1,6 @@
{
"name": "@openuidev/react-headless",
- "version": "0.8.2",
+ "version": "0.9.0",
"description": "Headless React primitives for AI chat — state management, streaming adapters for OpenAI and AG-UI, message format converters, and thread management for OpenUI generative UI apps",
"license": "MIT",
"type": "module",
@@ -77,6 +77,6 @@
"url": "https://github.com/thesysdev/openui/issues"
},
"dependencies": {
- "@ag-ui/core": "^0.0.45"
+ "@ag-ui/core": "^0.0.53"
}
}
diff --git a/packages/react-headless/src/adapters/__tests__/restStorage.test.ts b/packages/react-headless/src/adapters/__tests__/restStorage.test.ts
new file mode 100644
index 000000000..8ad6f081c
--- /dev/null
+++ b/packages/react-headless/src/adapters/__tests__/restStorage.test.ts
@@ -0,0 +1,105 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Thread, UserMessage } from "../../store/types";
+import type { MessageFormat } from "../../types/messageFormat";
+import { restStorage } from "../restStorage";
+
+const json = (body: unknown, ok = true, status = 200) =>
+ new Response(JSON.stringify(body), { status, statusText: ok ? "OK" : "Error" });
+
+describe("restStorage", () => {
+ let fetchSpy: ReturnType;
+
+ beforeEach(() => {
+ fetchSpy = vi.fn();
+ });
+
+ const make = (overrides?: { messageFormat?: MessageFormat; headers?: Record }) =>
+ restStorage({
+ baseUrl: "/api/threads",
+ fetch: fetchSpy as unknown as typeof fetch,
+ ...overrides,
+ }).thread;
+
+ it("listThreads GETs {base}/get and returns the payload", async () => {
+ const payload = { threads: [{ id: "t1", title: "A", createdAt: 0 }], nextCursor: "c2" };
+ fetchSpy.mockResolvedValue(json(payload));
+
+ const result = await make().listThreads();
+
+ expect(fetchSpy).toHaveBeenCalledWith("/api/threads/get", expect.objectContaining({}));
+ expect(result).toEqual(payload);
+ });
+
+ it("listThreads passes cursor as query param", async () => {
+ fetchSpy.mockResolvedValue(json({ threads: [] }));
+ await make().listThreads("abc");
+ expect(fetchSpy.mock.calls[0][0]).toBe("/api/threads/get?cursor=abc");
+ });
+
+ it("createThread POSTs {base}/create with messageFormat.toApi applied", async () => {
+ const thread: Thread = { id: "t-new", title: "New", createdAt: 0 };
+ fetchSpy.mockResolvedValue(json(thread));
+ const toApi = vi.fn((msgs) => msgs.map((m: any) => ({ custom: m.id })));
+ const messageFormat: MessageFormat = { toApi, fromApi: (d) => d as any };
+
+ const result = await make({ messageFormat }).createThread({
+ id: "m1",
+ role: "user",
+ content: "hi",
+ } as UserMessage);
+
+ const [url, opts] = fetchSpy.mock.calls[0];
+ expect(url).toBe("/api/threads/create");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(toApi).toHaveBeenCalled();
+ expect(JSON.parse(opts.body)).toEqual({ messages: [{ custom: "m1" }] });
+ expect(result).toEqual(thread);
+ });
+
+ it("getMessages GETs {base}/get/:id and applies messageFormat.fromApi", async () => {
+ const raw = [{ r: "user", c: "hi" }];
+ const parsed = [{ id: "m1", role: "user", content: "hi" }];
+ fetchSpy.mockResolvedValue(json(raw));
+ const fromApi = vi.fn().mockReturnValue(parsed);
+
+ const result = await make({ messageFormat: { toApi: (m) => m, fromApi } }).getMessages("t1");
+
+ expect(fetchSpy.mock.calls[0][0]).toBe("/api/threads/get/t1");
+ expect(fromApi).toHaveBeenCalledWith(raw);
+ expect(result).toEqual(parsed);
+ });
+
+ it("updateThread PATCHes {base}/update/:id with the thread body", async () => {
+ const updated: Thread = { id: "t1", title: "Renamed", createdAt: 0 };
+ fetchSpy.mockResolvedValue(json(updated));
+
+ const result = await make().updateThread(updated);
+
+ const [url, opts] = fetchSpy.mock.calls[0];
+ expect(url).toBe("/api/threads/update/t1");
+ expect(opts.method).toBe("PATCH");
+ expect(JSON.parse(opts.body)).toEqual(updated);
+ expect(result).toEqual(updated);
+ });
+
+ it("deleteThread DELETEs {base}/delete/:id", async () => {
+ fetchSpy.mockResolvedValue(json({}, true));
+ await make().deleteThread("t1");
+ expect(fetchSpy).toHaveBeenCalledWith(
+ "/api/threads/delete/t1",
+ expect.objectContaining({ method: "DELETE" }),
+ );
+ });
+
+ it("merges custom headers into requests", async () => {
+ fetchSpy.mockResolvedValue(json({ threads: [] }));
+ await make({ headers: { Authorization: "Bearer x" } }).listThreads();
+ expect(fetchSpy.mock.calls[0][1].headers).toMatchObject({ Authorization: "Bearer x" });
+ });
+
+ it("throws a descriptive error when res.ok is false", async () => {
+ fetchSpy.mockResolvedValue(json({ error: "nope" }, false, 500));
+ await expect(make().listThreads()).rejects.toThrow(/GET \/api\/threads\/get failed: 500/);
+ });
+});
diff --git a/packages/react-headless/src/adapters/_defaultStorage.ts b/packages/react-headless/src/adapters/_defaultStorage.ts
new file mode 100644
index 000000000..85fec9617
--- /dev/null
+++ b/packages/react-headless/src/adapters/_defaultStorage.ts
@@ -0,0 +1,48 @@
+import type { Thread } from "../store/types";
+import type { Message, UserMessage } from "../types/message";
+import type { ChatStorage } from "./types";
+
+/**
+ * Internal default storage — in-memory, no persistence across reload.
+ * Used by `` when no `storage` prop is provided. Not exported
+ * from the package; callers who want explicit in-memory behavior should
+ * construct their own adapter object.
+ */
+export function createDefaultInMemoryStorage(): ChatStorage {
+ let threads: Thread[] = [];
+ const messagesByThread = new Map();
+
+ return {
+ thread: {
+ async listThreads() {
+ return { threads };
+ },
+ async createThread(firstMessage: UserMessage) {
+ const thread: Thread = {
+ id: crypto.randomUUID(),
+ title:
+ typeof firstMessage.content === "string"
+ ? firstMessage.content.slice(0, 40) || "New thread"
+ : "New thread",
+ createdAt: new Date().toISOString(),
+ };
+ threads = [thread, ...threads];
+ messagesByThread.set(thread.id, [
+ { ...firstMessage, id: firstMessage.id ?? crypto.randomUUID() },
+ ]);
+ return thread;
+ },
+ async getMessages(threadId: string) {
+ return messagesByThread.get(threadId) ?? [];
+ },
+ async updateThread(thread: Thread) {
+ threads = threads.map((t) => (t.id === thread.id ? thread : t));
+ return thread;
+ },
+ async deleteThread(id: string) {
+ threads = threads.filter((t) => t.id !== id);
+ messagesByThread.delete(id);
+ },
+ },
+ };
+}
diff --git a/packages/react-headless/src/adapters/fetchLLM.ts b/packages/react-headless/src/adapters/fetchLLM.ts
new file mode 100644
index 000000000..29929f172
--- /dev/null
+++ b/packages/react-headless/src/adapters/fetchLLM.ts
@@ -0,0 +1,56 @@
+import { identityMessageFormat, type MessageFormat } from "../types/messageFormat";
+import type { StreamProtocolAdapter } from "../types/stream";
+import type { ChatLLM } from "./types";
+
+export interface FetchLLMOptions {
+ /** Endpoint that accepts POST'd messages and returns a streaming Response. */
+ url: string;
+ /** Stream protocol adapter for parsing the response body (e.g., agUIAdapter, openAIAdapter). */
+ streamAdapter: StreamProtocolAdapter;
+ /** Wire-format conversion for outgoing messages. Defaults to identity (canonical Message). */
+ messageFormat?: MessageFormat;
+ /** Extra headers merged into the request. */
+ headers?: Record;
+ /** Override fetch implementation (for tests, custom auth wrappers, etc.). */
+ fetch?: typeof fetch;
+}
+
+/**
+ * Generic HTTP-based LLM adapter. POSTs an AG-UI `RunAgentInput`-shaped body
+ * (`{ threadId, runId, messages, tools, context }`, messages in the chosen wire
+ * format) to `url` and returns the streaming `Response` for downstream processing.
+ *
+ * The fields the {@link ChatLLM} `send` contract doesn't carry are defaulted
+ * here so the body satisfies a spec-compliant AG-UI agent: a fresh `runId` is
+ * generated per send, and `tools`/`context` default to `[]` (override via options).
+ */
+export function fetchLLM({
+ url,
+ streamAdapter,
+ messageFormat = identityMessageFormat,
+ headers,
+ fetch: customFetch,
+}: FetchLLMOptions): ChatLLM {
+ const fetchImpl = customFetch ?? globalThis.fetch.bind(globalThis);
+ return {
+ send: ({ threadId, messages, signal }) => {
+ const wire = messageFormat.toApi(messages);
+ return fetchImpl(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...headers,
+ },
+ body: JSON.stringify({
+ threadId,
+ runId: crypto.randomUUID(),
+ messages: wire,
+ tools: [],
+ context: [],
+ }),
+ signal,
+ });
+ },
+ streamProtocol: streamAdapter,
+ };
+}
diff --git a/packages/react-headless/src/adapters/index.ts b/packages/react-headless/src/adapters/index.ts
new file mode 100644
index 000000000..3e45d59be
--- /dev/null
+++ b/packages/react-headless/src/adapters/index.ts
@@ -0,0 +1,18 @@
+export type {
+ Artifact,
+ ArtifactCategory,
+ ArtifactListParams,
+ ArtifactStorage,
+ ArtifactSummary,
+ ChatLLM,
+ ChatStorage,
+ ThreadStorage,
+} from "./types";
+
+export { fetchLLM } from "./fetchLLM";
+export type { FetchLLMOptions } from "./fetchLLM";
+
+export { restStorage } from "./restStorage";
+export type { RestStorageOptions } from "./restStorage";
+
+// _defaultStorage is intentionally NOT exported — it's internal to ChatProvider.
diff --git a/packages/react-headless/src/adapters/restStorage.ts b/packages/react-headless/src/adapters/restStorage.ts
new file mode 100644
index 000000000..d4a2af9b7
--- /dev/null
+++ b/packages/react-headless/src/adapters/restStorage.ts
@@ -0,0 +1,89 @@
+import type { Thread, UserMessage } from "../store/types";
+import type { Message } from "../types/message";
+import { identityMessageFormat, type MessageFormat } from "../types/messageFormat";
+import type { ChatStorage } from "./types";
+
+export interface RestStorageOptions {
+ /**
+ * Base URL for thread endpoints (the old `threadApiUrl`). The factory hits
+ * the same conventions the legacy default used:
+ * - list: GET {baseUrl}/get (· ?cursor={cursor})
+ * - create: POST {baseUrl}/create
+ * - get: GET {baseUrl}/get/{threadId}
+ * - update: PATCH {baseUrl}/update/{threadId}
+ * - delete: DELETE {baseUrl}/delete/{threadId}
+ */
+ baseUrl: string;
+ /** Wire-format conversion. Defaults to identity (canonical Message). */
+ messageFormat?: MessageFormat;
+ /** Extra headers merged into every request. */
+ headers?: Record;
+ /** Override fetch implementation (for tests, custom auth wrappers, etc.). */
+ fetch?: typeof fetch;
+}
+
+/**
+ * Generic REST-based thread storage. Reproduces the conventions the removed
+ * `threadApiUrl` prop used, so an existing backend keeps working by swapping
+ * `threadApiUrl="/x"` for `storage={restStorage({ baseUrl: "/x" })}`.
+ *
+ * Only the `thread` channel is implemented. Pair with an `llm` adapter
+ * (e.g. `fetchLLM`).
+ */
+export function restStorage({
+ baseUrl,
+ messageFormat = identityMessageFormat,
+ headers,
+ fetch: customFetch,
+}: RestStorageOptions): ChatStorage {
+ const fetchImpl = customFetch ?? globalThis.fetch.bind(globalThis);
+
+ const request = async (url: string, init?: RequestInit): Promise => {
+ const res = await fetchImpl(url, {
+ ...init,
+ headers: {
+ ...(init?.body ? { "Content-Type": "application/json" } : {}),
+ ...headers,
+ ...init?.headers,
+ },
+ });
+ if (!res.ok) {
+ throw new Error(
+ `restStorage: ${init?.method ?? "GET"} ${url} failed: ${res.status} ${res.statusText}`,
+ );
+ }
+ return res;
+ };
+
+ return {
+ thread: {
+ async listThreads(cursor?: string) {
+ const url = cursor ? `${baseUrl}/get?cursor=${cursor}` : `${baseUrl}/get`;
+ const res = await request(url);
+ return res.json();
+ },
+ async createThread(firstMessage: UserMessage): Promise {
+ const res = await request(`${baseUrl}/create`, {
+ method: "POST",
+ body: JSON.stringify({ messages: messageFormat.toApi([firstMessage]) }),
+ });
+ return res.json();
+ },
+ async getMessages(threadId: string): Promise {
+ const res = await request(`${baseUrl}/get/${threadId}`);
+ const raw: unknown = await res.json();
+ return messageFormat.fromApi(raw);
+ },
+ async updateThread(thread: Thread): Promise {
+ const res = await request(`${baseUrl}/update/${thread.id}`, {
+ method: "PATCH",
+ body: JSON.stringify(thread),
+ });
+ return res.json();
+ },
+ async deleteThread(id: string): Promise {
+ await request(`${baseUrl}/delete/${id}`, { method: "DELETE" });
+ },
+ },
+ };
+}
diff --git a/packages/react-headless/src/adapters/types.ts b/packages/react-headless/src/adapters/types.ts
new file mode 100644
index 000000000..6c8af8828
--- /dev/null
+++ b/packages/react-headless/src/adapters/types.ts
@@ -0,0 +1,86 @@
+import type { ReactNode } from "react";
+import type { Thread } from "../store/types";
+import type { Message, UserMessage } from "../types/message";
+import type { StreamProtocolAdapter } from "../types/stream";
+
+// ── Storage adapter interfaces ──
+
+export interface ThreadStorage {
+ listThreads(cursor?: string): Promise<{ threads: Thread[]; nextCursor?: string }>;
+ createThread(firstMessage: UserMessage): Promise;
+ getMessages(threadId: string): Promise;
+ updateThread(thread: Thread): Promise;
+ deleteThread(id: string): Promise;
+}
+
+// ── Artifact storage (global, cross-thread) ──
+
+/** Listing-level artifact record. `content` is fetched separately via `get`. */
+export interface ArtifactSummary {
+ id: string;
+ title: string;
+ /** Artifact type, e.g. `'th_dashboard'`, `'th_presentation'`. Matched against renderer `type` and category filters. */
+ type: string;
+ /** Thread the artifact was created in. Drives the "go to original thread" action. */
+ threadId: string;
+ updatedAt?: string | number;
+}
+
+/** Full artifact. `content` must have the same shape as the tool-call `response` the renderer's parser expects. */
+export interface Artifact extends ArtifactSummary {
+ content: unknown;
+}
+
+export interface ArtifactListParams {
+ /** Partial-match search on `title`. Server-side. */
+ name?: string;
+ /** Filter by artifact types. Server-side. */
+ type?: string[];
+ cursor?: string;
+ limit?: number;
+}
+
+export interface ArtifactStorage {
+ list(params?: ArtifactListParams): Promise<{ artifacts: ArtifactSummary[]; nextCursor?: string }>;
+ get(id: string): Promise;
+ /** Persist edited artifact content. Called by renderer implementations (via `useArtifactStorage`), not by the framework. */
+ update(patch: { id: string; content: unknown }): Promise;
+}
+
+/**
+ * Global artifact category. Categories split the sidebar "Artifacts" nav and
+ * the per-thread Workspace sections, and pre-apply filters in the artifact browser.
+ */
+export interface ArtifactCategory {
+ /** Display label + key, e.g. `'Apps'`. */
+ name: string;
+ filter: {
+ /** Artifact types belonging to this category. */
+ type: string[];
+ };
+ /**
+ * Icon for this category's sidebar nav item. A platform-neutral node (a web
+ * element or a React Native element). When omitted, the nav falls back to the
+ * `` prop, then a generic default.
+ */
+ icon?: ReactNode;
+}
+
+export interface ChatStorage {
+ thread: ThreadStorage;
+ /** Optional global artifact storage. Absent → the Artifacts nav and browser are unavailable. */
+ artifact?: ArtifactStorage;
+ // search, ... — added as features land
+}
+
+// ── LLM adapter interface ──
+
+export interface ChatLLM {
+ send(params: { threadId: string; messages: Message[]; signal: AbortSignal }): Promise;
+ streamProtocol: StreamProtocolAdapter;
+}
+
+// Re-exports kept here so adapter consumers can import everything in one shot.
+export type { Thread } from "../store/types";
+export type { Message, UserMessage } from "../types/message";
+export type { StreamProtocolAdapter } from "../types/stream";
diff --git a/packages/react-headless/src/hooks/useActiveArtifact.ts b/packages/react-headless/src/hooks/useActiveArtifact.ts
deleted file mode 100644
index b5d48b082..000000000
--- a/packages/react-headless/src/hooks/useActiveArtifact.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { useCallback } from "react";
-import { useStore } from "zustand";
-import { useArtifactStore } from "../store/ArtifactContext";
-
-/**
- * Return type for {@link useActiveArtifact}.
- *
- * @category Hooks
- */
-type UseActiveArtifactReturn = {
- /** Whether any artifact is currently active (panel is open). */
- isArtifactActive: boolean;
- /** The ID of the currently active artifact, or `null` if none. */
- activeArtifactId: string | null;
- /** Closes whichever artifact is currently active. No-op if none is active. */
- closeArtifact: () => void;
-};
-
-/**
- * Returns global artifact activation state — whether *any* artifact is open,
- * and a close action that dismisses it.
- *
- * Use this in layout components that react to artifact presence (resizing panels,
- * showing overlays) without needing to know *which* artifact is active.
- * For per-artifact state and actions, use {@link useArtifact} instead.
- *
- * Must be called within a ``.
- *
- * @category Hooks
- * @returns {@link UseActiveArtifactReturn}
- */
-export function useActiveArtifact(): UseActiveArtifactReturn {
- const store = useArtifactStore();
-
- const activeArtifactId = useStore(store, (s) => s.activeArtifactId);
- const isArtifactActive = activeArtifactId !== null;
-
- const closeArtifact = useCallback(() => {
- const state = store.getState();
- if (state.activeArtifactId) {
- state.closeArtifact(state.activeArtifactId);
- }
- }, [store]);
-
- return { isArtifactActive, activeArtifactId, closeArtifact };
-}
diff --git a/packages/react-headless/src/hooks/useActiveDetailedView.ts b/packages/react-headless/src/hooks/useActiveDetailedView.ts
new file mode 100644
index 000000000..34bc705b6
--- /dev/null
+++ b/packages/react-headless/src/hooks/useActiveDetailedView.ts
@@ -0,0 +1,45 @@
+import { useCallback } from "react";
+import { useStore } from "zustand";
+import { useDetailedViewStore } from "../store/DetailedViewContext";
+
+/**
+ * Return type for {@link useActiveDetailedView}.
+ *
+ * @category Hooks
+ */
+type UseActiveDetailedViewReturn = {
+ /** Whether any detailed view is currently active (panel is open). */
+ isDetailedViewActive: boolean;
+ /** The id of the currently active detailed view, or `null` if none. */
+ activeDetailedViewId: string | null;
+ /** Closes whichever detailed view is currently active. No-op if none is active. */
+ closeDetailedView: () => void;
+};
+
+/**
+ * Returns global detailed-view activation state — whether *any* view is open,
+ * and a close action that dismisses it.
+ *
+ * Use this in layout components that react to detailed-view presence (resizing
+ * panels, showing overlays) without needing to know *which* view is active.
+ * For per-view state and actions, use {@link useDetailedView} instead.
+ *
+ * Must be called within a ``.
+ *
+ * @category Hooks
+ * @returns {@link UseActiveDetailedViewReturn}
+ */
+export function useActiveDetailedView(): UseActiveDetailedViewReturn {
+ const store = useDetailedViewStore();
+
+ const activeDetailedViewId = useStore(store, (s) => s.activeDetailedViewId);
+ const isDetailedViewActive = activeDetailedViewId !== null;
+
+ const closeDetailedView = useCallback(() => {
+ if (store.getState().activeDetailedViewId !== null) {
+ store.getState().setActiveDetailedView(null);
+ }
+ }, [store]);
+
+ return { isDetailedViewActive, activeDetailedViewId, closeDetailedView };
+}
diff --git a/packages/react-headless/src/hooks/useArtifact.ts b/packages/react-headless/src/hooks/useArtifact.ts
deleted file mode 100644
index ff1775958..000000000
--- a/packages/react-headless/src/hooks/useArtifact.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { useCallback } from "react";
-import { useStore } from "zustand";
-import { useArtifactStore } from "../store/ArtifactContext";
-
-/**
- * Return type for {@link useArtifact}.
- *
- * @category Hooks
- */
-type UseArtifactReturn = {
- /** Whether this artifact is the currently active (visible) one. */
- isActive: boolean;
- /** Activates this artifact. */
- open: () => void;
- /** Deactivates this artifact. */
- close: () => void;
- /** Toggles this artifact: opens if closed, closes if open. */
- toggle: () => void;
-};
-
-/**
- * Binds a component to a specific artifact by ID, providing activation state
- * and actions (open, close, toggle).
- *
- * Multiple `useArtifact` hooks with different IDs can coexist —
- * only one artifact is active at a time.
- *
- * Must be called within a ``.
- *
- * @category Hooks
- * @param artifactId - Unique identifier for the artifact
- * @returns {@link UseArtifactReturn}
- *
- * @example
- * ```tsx
- * function PreviewButton({ id }: { id: string }) {
- * const { isActive, toggle } = useArtifact(id);
- * return (
- * toggle()}>
- * {isActive ? "Hide" : "Show"} Preview
- *
- * );
- * }
- * ```
- */
-export function useArtifact(artifactId: string): UseArtifactReturn {
- const store = useArtifactStore();
-
- const isActive = useStore(store, (s) => s.activeArtifactId === artifactId);
-
- const open = useCallback(() => {
- store.getState().openArtifact(artifactId);
- }, [store, artifactId]);
-
- const close = useCallback(() => {
- store.getState().closeArtifact(artifactId);
- }, [store, artifactId]);
-
- const toggle = useCallback(() => {
- const state = store.getState();
- if (state.activeArtifactId === artifactId) {
- state.closeArtifact(artifactId);
- } else {
- state.openArtifact(artifactId);
- }
- }, [store, artifactId]);
-
- return { isActive, open, close, toggle };
-}
diff --git a/packages/react-headless/src/hooks/useArtifactList.ts b/packages/react-headless/src/hooks/useArtifactList.ts
new file mode 100644
index 000000000..9fd728ade
--- /dev/null
+++ b/packages/react-headless/src/hooks/useArtifactList.ts
@@ -0,0 +1,55 @@
+import { useMemo } from "react";
+import { useStore } from "zustand";
+import { useThreadContextStore } from "../store/ThreadContextContext";
+import type { ArtifactEntry } from "../store/threadContextTypes";
+
+export interface ArtifactListFilter {
+ /** Only entries whose `type` is in this list. Omit for all entries. */
+ type?: string[];
+}
+
+/**
+ * Returns artifacts registered in the active thread, grouped by `id` and
+ * sorted ascending by `version`. The latest version of each artifact is the
+ * last element.
+ *
+ * Pass a filter to restrict by artifact `type` — e.g. the types from an
+ * `ArtifactCategory` to build category-grouped workspace sections.
+ *
+ * Must be called within a ``.
+ *
+ * @category Hooks
+ * @returns Map of artifact id → ordered version list
+ *
+ * @example
+ * ```tsx
+ * function WorkspaceSection({ category }: { category: ArtifactCategory }) {
+ * const artifacts = useArtifactList({ type: category.filter.type });
+ * const latest = Object.values(artifacts).map((v) => v[v.length - 1]);
+ * return (
+ *
+ * {latest.map((a) => (
+ * {a.heading}
+ * ))}
+ *
+ * );
+ * }
+ * ```
+ */
+export function useArtifactList(filter?: ArtifactListFilter): Record {
+ const store = useThreadContextStore();
+ const artifacts = useStore(store, (s) => s.artifacts);
+ const typeKey = filter?.type?.join(" ");
+
+ return useMemo(() => {
+ if (typeKey === undefined) return artifacts;
+ const allowed = new Set(typeKey.split(" "));
+ const result: Record = {};
+ for (const [id, versions] of Object.entries(artifacts)) {
+ // All versions of an id share a type in practice; filter on the latest.
+ const latest = versions[versions.length - 1];
+ if (latest && allowed.has(latest.type)) result[id] = versions;
+ }
+ return result;
+ }, [artifacts, typeKey]);
+}
diff --git a/packages/react-headless/src/hooks/useArtifactRenderer.ts b/packages/react-headless/src/hooks/useArtifactRenderer.ts
new file mode 100644
index 000000000..f8cae4326
--- /dev/null
+++ b/packages/react-headless/src/hooks/useArtifactRenderer.ts
@@ -0,0 +1,23 @@
+import {
+ lookupArtifactRenderer,
+ useArtifactRendererRegistry,
+} from "../store/ArtifactRenderersContext";
+import type { ArtifactRendererConfig } from "../store/artifactRendererTypes";
+
+/**
+ * Resolves the artifact-renderer config matching a given `toolName`, or `null`
+ * if none match.
+ *
+ * Thin React wrapper over {@link lookupArtifactRenderer}: reads the registry
+ * from `` context and runs the lookup.
+ *
+ * Returns `null` if no `artifactRenderers` were supplied to the provider —
+ * callers should fall back to default rendering in that case.
+ *
+ * @category Hooks
+ */
+export function useArtifactRenderer(toolName: string): ArtifactRendererConfig | null {
+ const registry = useArtifactRendererRegistry();
+ if (!registry) return null;
+ return lookupArtifactRenderer(registry, toolName);
+}
diff --git a/packages/react-headless/src/hooks/useDetailedView.ts b/packages/react-headless/src/hooks/useDetailedView.ts
new file mode 100644
index 000000000..20efd09ec
--- /dev/null
+++ b/packages/react-headless/src/hooks/useDetailedView.ts
@@ -0,0 +1,73 @@
+import { useCallback } from "react";
+import { useStore } from "zustand";
+import { useDetailedViewStore } from "../store/DetailedViewContext";
+
+/**
+ * Return type for {@link useDetailedView}.
+ *
+ * @category Hooks
+ */
+type UseDetailedViewReturn = {
+ /** Whether this view is the currently active (visible) one. */
+ isActive: boolean;
+ /** Activates this view as the side panel. */
+ open: () => void;
+ /** Closes this view if it is currently active. */
+ close: () => void;
+ /** Toggles this view: opens if closed, closes if open. */
+ toggle: () => void;
+};
+
+/**
+ * Binds a component to a specific detailed view by id, providing activation
+ * state and actions (open, close, toggle).
+ *
+ * Only one detailed view is active at a time across all kinds (apps, artifacts,
+ * and custom consumers). The `viewId` format is renderer-defined. The built-in
+ * App and Artifact renderers use `"${id}:${version}"`; custom consumers may
+ * pick any unique string.
+ *
+ * Must be called within a ``.
+ *
+ * @category Hooks
+ * @param viewId - Unique identifier for the detailed view
+ * @returns {@link UseDetailedViewReturn}
+ *
+ * @example
+ * ```tsx
+ * function PreviewButton({ viewId }: { viewId: string }) {
+ * const { isActive, toggle } = useDetailedView(viewId);
+ * return (
+ *
+ * {isActive ? "Hide" : "Show"} Preview
+ *
+ * );
+ * }
+ * ```
+ */
+export function useDetailedView(viewId: string): UseDetailedViewReturn {
+ const store = useDetailedViewStore();
+
+ const isActive = useStore(store, (s) => s.activeDetailedViewId === viewId);
+
+ const open = useCallback(() => {
+ store.getState().setActiveDetailedView(viewId);
+ }, [store, viewId]);
+
+ const close = useCallback(() => {
+ if (store.getState().activeDetailedViewId === viewId) {
+ store.getState().setActiveDetailedView(null);
+ }
+ }, [store, viewId]);
+
+ const toggle = useCallback(() => {
+ const state = store.getState();
+ if (state.activeDetailedViewId === viewId) {
+ state.setActiveDetailedView(null);
+ } else {
+ state.setActiveDetailedView(viewId);
+ }
+ }, [store, viewId]);
+
+ return { isActive, open, close, toggle };
+}
diff --git a/packages/react-headless/src/hooks/useArtifactPortalTarget.ts b/packages/react-headless/src/hooks/useDetailedViewPortalTarget.ts
similarity index 56%
rename from packages/react-headless/src/hooks/useArtifactPortalTarget.ts
rename to packages/react-headless/src/hooks/useDetailedViewPortalTarget.ts
index f7abfec65..be055cc6b 100644
--- a/packages/react-headless/src/hooks/useArtifactPortalTarget.ts
+++ b/packages/react-headless/src/hooks/useDetailedViewPortalTarget.ts
@@ -1,13 +1,13 @@
import { useCallback } from "react";
import { useStore } from "zustand";
-import { useArtifactStore } from "../store/ArtifactContext";
+import { useDetailedViewStore } from "../store/DetailedViewContext";
/**
- * Provides access to the artifact portal target DOM node.
+ * Provides access to the detailed-view portal target DOM node.
*
* This hook serves two roles:
* - **Registering a portal target:** Call `setNode` from a ref callback to
- * designate a DOM element as the render target for artifact content.
+ * designate a DOM element as the render target for detailed-view content.
* Only one target should be registered at a time.
* - **Reading the portal target:** Read `node` to get the current target
* element for use with `createPortal()`.
@@ -21,26 +21,26 @@ import { useArtifactStore } from "../store/ArtifactContext";
* ```tsx
* // Registering a portal target
* function MyPortalTarget() {
- * const { setNode } = useArtifactPortalTarget();
+ * const { setNode } = useDetailedViewPortalTarget();
* return
;
* }
*
- * // Building a custom artifact panel
- * function MyArtifactPanel({ artifactId, children }) {
- * const { isActive } = useArtifact(artifactId);
- * const { node } = useArtifactPortalTarget();
+ * // Building a custom detailed-view panel
+ * function MyDetailedViewPanel({ viewId, children }) {
+ * const { isActive } = useDetailedView(viewId);
+ * const { node } = useDetailedViewPortalTarget();
* if (!isActive || !node) return null;
* return createPortal({children}
, node);
* }
* ```
*/
-export function useArtifactPortalTarget() {
- const store = useArtifactStore();
- const node = useStore(store, (s) => s._artifactPanelNode);
+export function useDetailedViewPortalTarget() {
+ const store = useDetailedViewStore();
+ const node = useStore(store, (s) => s._detailedViewPanelNode);
const setNode = useCallback(
(node: HTMLElement | null) => {
- store.getState()._setArtifactPanelNode(node);
+ store.getState()._setDetailedViewPanelNode(node);
},
[store],
);
diff --git a/packages/react-headless/src/hooks/useThread.ts b/packages/react-headless/src/hooks/useThread.ts
index 68dfc6b59..55c601250 100644
--- a/packages/react-headless/src/hooks/useThread.ts
+++ b/packages/react-headless/src/hooks/useThread.ts
@@ -17,6 +17,7 @@ const threadSelector = (s: ChatStore): ThreadSlice => ({
isRunning: s.isRunning,
isLoadingMessages: s.isLoadingMessages,
threadError: s.threadError,
+ executingToolCallIds: s.executingToolCallIds,
processMessage: s.processMessage,
appendMessages: s.appendMessages,
updateMessage: s.updateMessage,
diff --git a/packages/react-headless/src/hooks/useToolActivities.ts b/packages/react-headless/src/hooks/useToolActivities.ts
new file mode 100644
index 000000000..5260ba385
--- /dev/null
+++ b/packages/react-headless/src/hooks/useToolActivities.ts
@@ -0,0 +1,38 @@
+import { useMemo } from "react";
+import { pairToolActivity, type ToolActivity } from "../store/toolActivity";
+import type { AssistantMessage, Message, ToolMessage } from "../types";
+import { useThread } from "./useThread";
+
+/**
+ * Memoized view of an assistant message's tool calls paired with their results,
+ * as a typed {@link ToolActivity} array.
+ *
+ * Wraps {@link pairToolActivity}, re-pairing only when something that affects
+ * the result actually changes: an argument string grows, a tool result lands or
+ * changes length/error, or the store's executing set changes. Keying on both
+ * args length **and** result length matters — a result can arrive after args
+ * are already closed, and vice versa.
+ *
+ * @category Hooks
+ */
+export function useToolActivities(
+ message: AssistantMessage,
+ allMessages: Message[],
+): ToolActivity[] {
+ const executingIds = useThread((s) => s.executingToolCallIds);
+
+ const argsKey = (message.toolCalls ?? [])
+ .map((t) => `${t.id}:${t.function.arguments.length}`)
+ .join("|");
+
+ const resultKey = allMessages
+ .filter((m): m is ToolMessage => m.role === "tool")
+ .map((m) => `${m.toolCallId}:${m.content?.length ?? 0}:${m.error ? 1 : 0}`)
+ .join("|");
+
+ return useMemo(
+ () => pairToolActivity(message, allMessages, executingIds),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [message.id, argsKey, resultKey, executingIds],
+ );
+}
diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts
index 868af6412..6bd05b30a 100644
--- a/packages/react-headless/src/index.ts
+++ b/packages/react-headless/src/index.ts
@@ -1,11 +1,28 @@
-export { useActiveArtifact } from "./hooks/useActiveArtifact";
-export { useArtifact } from "./hooks/useArtifact";
-export { useArtifactPortalTarget } from "./hooks/useArtifactPortalTarget";
+export { useActiveDetailedView } from "./hooks/useActiveDetailedView";
+export { useArtifactList } from "./hooks/useArtifactList";
+export type { ArtifactListFilter } from "./hooks/useArtifactList";
+export { useArtifactRenderer } from "./hooks/useArtifactRenderer";
+export { useDetailedView } from "./hooks/useDetailedView";
+export { useDetailedViewPortalTarget } from "./hooks/useDetailedViewPortalTarget";
export { MessageContext, MessageProvider, useMessage } from "./hooks/useMessage";
export { useThread, useThreadList } from "./hooks/useThread";
+export { useToolActivities } from "./hooks/useToolActivities";
-export { ArtifactContext, useArtifactStore } from "./store/ArtifactContext";
+export { defineArtifactCategories } from "./store/artifactCategories";
+export type { ArtifactCategoryGroup } from "./store/artifactCategories";
+export { useArtifactCategories } from "./store/ArtifactCategoriesContext";
+export {
+ ArtifactRenderersContext,
+ lookupArtifactRenderer,
+ lookupArtifactRendererByType,
+ useArtifactRendererRegistry,
+} from "./store/ArtifactRenderersContext";
+export { defineArtifactRenderer } from "./store/artifactRendererTypes";
+export { useArtifactStorage } from "./store/ArtifactStorageContext";
export { ChatProvider } from "./store/ChatProvider";
+export { DetailedViewContext, useDetailedViewStore } from "./store/DetailedViewContext";
+export { ThreadContextContext, useThreadContextStore } from "./store/ThreadContextContext";
+export { pairToolActivity, partialJSONParse } from "./store/toolActivity";
export {
agUIAdapter,
langGraphAdapter,
@@ -20,7 +37,37 @@ export {
} from "./stream/formats";
export { processStreamedMessage } from "./stream/processStreamedMessage";
-export type { ArtifactActions, ArtifactState } from "./store/artifactTypes";
+// ── Adapter interfaces + factories ──
+export { fetchLLM, restStorage } from "./adapters";
+export type {
+ Artifact,
+ ArtifactCategory,
+ ArtifactListParams,
+ ArtifactStorage,
+ ArtifactSummary,
+ ChatLLM,
+ ChatStorage,
+ FetchLLMOptions,
+ RestStorageOptions,
+ ThreadStorage,
+} from "./adapters";
+
+export type {
+ ArtifactRendererConfig,
+ ArtifactRendererControls,
+ ParsedArtifact,
+} from "./store/artifactRendererTypes";
+
+export type { ToolActivity, ToolCallStatus } from "./store/toolActivity";
+
+export type { DetailedViewActions, DetailedViewState } from "./store/detailedViewTypes";
+
+export type {
+ ArtifactEntry,
+ ThreadContextActions,
+ ThreadContextState,
+ ThreadContextStore,
+} from "./store/threadContextTypes";
export type {
ChatProviderProps,
diff --git a/packages/react-headless/src/store/ArtifactCategoriesContext.ts b/packages/react-headless/src/store/ArtifactCategoriesContext.ts
new file mode 100644
index 000000000..e820c5f1c
--- /dev/null
+++ b/packages/react-headless/src/store/ArtifactCategoriesContext.ts
@@ -0,0 +1,17 @@
+import { createContext, useContext } from "react";
+import type { ArtifactCategory } from "../adapters/types";
+
+/** @internal Provided by `ChatProvider` from the `artifactCategories` prop. */
+export const ArtifactCategoriesContext = createContext([]);
+
+/**
+ * Returns the global artifact categories configured on ``.
+ * Empty array when none were provided.
+ *
+ * Categories drive the sidebar Artifacts split, the artifact browser's
+ * pre-applied filters, and category-grouped workspace sections.
+ *
+ * @category Hooks
+ */
+export const useArtifactCategories = (): ArtifactCategory[] =>
+ useContext(ArtifactCategoriesContext);
diff --git a/packages/react-headless/src/store/ArtifactContext.ts b/packages/react-headless/src/store/ArtifactContext.ts
deleted file mode 100644
index 153a39334..000000000
--- a/packages/react-headless/src/store/ArtifactContext.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { createContext, useContext } from "react";
-import type { StoreApi } from "zustand";
-import type { ArtifactStore } from "./artifactTypes";
-
-/** @internal React context holding the artifact Zustand store. Provided by `ChatProvider`. */
-export const ArtifactContext = createContext | null>(null);
-
-/**
- * Returns the raw artifact Zustand store for advanced use cases.
- *
- * Prefer {@link useArtifact} or {@link useActiveArtifact} for most cases —
- * this hook is an escape hatch when you need direct store access.
- *
- * @category Hooks
- * @returns The Zustand `StoreApi` instance
- * @throws Error if called outside a ``
- */
-export const useArtifactStore = (): StoreApi => {
- const store = useContext(ArtifactContext);
- if (!store) {
- throw new Error("useArtifactStore must be used within a ");
- }
- return store;
-};
diff --git a/packages/react-headless/src/store/ArtifactRenderersContext.ts b/packages/react-headless/src/store/ArtifactRenderersContext.ts
new file mode 100644
index 000000000..44c93777d
--- /dev/null
+++ b/packages/react-headless/src/store/ArtifactRenderersContext.ts
@@ -0,0 +1,99 @@
+import { createContext, useContext } from "react";
+import type { ArtifactRendererConfig } from "./artifactRendererTypes";
+
+/**
+ * Pre-built lookup structure for artifact-renderer matching.
+ *
+ * Built once at `ChatProvider` mount from the user-supplied `artifactRenderers`
+ * array. Subsequent prop changes are ignored (with a dev-mode warning) so
+ * renderer registration stays stable for the lifetime of the provider.
+ *
+ * @internal
+ */
+export type ArtifactRendererRegistry = {
+ /** toolName → renderer. A renderer with several toolNames appears once per name. */
+ byToolName: Map>;
+ /** artifact type → renderer. Used by the artifact browser to render stored artifacts. */
+ byType: Map>;
+};
+
+const isDev = () => typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production";
+
+/**
+ * Builds an {@link ArtifactRendererRegistry} from a list of configs.
+ *
+ * First-wins on duplicate `toolName` or `type`: subsequent registrations are
+ * ignored with a dev-mode warning so the user can reorder their array
+ * (custom renderers should come *before* SDK defaults).
+ *
+ * @internal
+ */
+export function buildArtifactRendererRegistry(
+ configs: ReadonlyArray>,
+): ArtifactRendererRegistry {
+ const byToolName = new Map>();
+ const byType = new Map>();
+
+ for (const config of configs) {
+ const toolNames = Array.isArray(config.toolName) ? config.toolName : [config.toolName];
+ for (const name of toolNames) {
+ if (byToolName.has(name)) {
+ if (isDev()) {
+ console.warn(
+ `[OpenUI] Artifact renderer for toolName "${name}" was ignored ` +
+ `(already registered earlier in the array).`,
+ );
+ }
+ continue;
+ }
+ byToolName.set(name, config);
+ }
+
+ if (byType.has(config.type)) {
+ if (isDev()) {
+ console.warn(
+ `[OpenUI] Artifact renderer for type "${config.type}" was ignored for type-based ` +
+ `lookup (already registered earlier in the array).`,
+ );
+ }
+ continue;
+ }
+ byType.set(config.type, config);
+ }
+
+ return { byToolName, byType };
+}
+
+/** Resolves the renderer matching a tool name, or `null`. @internal */
+export function lookupArtifactRenderer(
+ registry: ArtifactRendererRegistry,
+ toolName: string,
+): ArtifactRendererConfig | null {
+ return registry.byToolName.get(toolName) ?? null;
+}
+
+/** Resolves the renderer matching an artifact type, or `null`. @internal */
+export function lookupArtifactRendererByType(
+ registry: ArtifactRendererRegistry,
+ type: string,
+): ArtifactRendererConfig | null {
+ return registry.byType.get(type) ?? null;
+}
+
+/** @internal React context holding the renderer registry. Provided by `ChatProvider`. */
+export const ArtifactRenderersContext = createContext(null);
+
+/**
+ * Returns the raw artifact-renderer registry for advanced use cases.
+ *
+ * Prefer {@link useArtifactRenderer} for resolving a specific tool name —
+ * this hook is an escape hatch for custom dispatching.
+ *
+ * Returns `null` if no `artifactRenderers` were provided to the `` —
+ * this is not an error since renderers are optional.
+ *
+ * @category Hooks
+ */
+export const useArtifactRendererRegistry = (): ArtifactRendererRegistry | null => {
+ return useContext(ArtifactRenderersContext);
+};
diff --git a/packages/react-headless/src/store/ArtifactStorageContext.ts b/packages/react-headless/src/store/ArtifactStorageContext.ts
new file mode 100644
index 000000000..77c58e3ce
--- /dev/null
+++ b/packages/react-headless/src/store/ArtifactStorageContext.ts
@@ -0,0 +1,16 @@
+import { createContext, useContext } from "react";
+import type { ArtifactStorage } from "../adapters/types";
+
+/** @internal Provided by `ChatProvider` from `storage.artifact`. */
+export const ArtifactStorageContext = createContext(null);
+
+/**
+ * Returns the configured global {@link ArtifactStorage} channel, or `null`
+ * when the storage adapter doesn't provide one.
+ *
+ * Renderer implementations use this to lazily fetch (`get`) or persist
+ * (`update`) artifact content; the artifact browser uses it for `list`.
+ *
+ * @category Hooks
+ */
+export const useArtifactStorage = (): ArtifactStorage | null => useContext(ArtifactStorageContext);
diff --git a/packages/react-headless/src/store/ChatProvider.tsx b/packages/react-headless/src/store/ChatProvider.tsx
index 3ec368d64..76f708a26 100644
--- a/packages/react-headless/src/store/ChatProvider.tsx
+++ b/packages/react-headless/src/store/ChatProvider.tsx
@@ -1,27 +1,82 @@
-import { useEffect, useState, type FC } from "react";
-import { ArtifactContext } from "./ArtifactContext";
+import { useEffect, useRef, useState, type FC } from "react";
+import { createDefaultInMemoryStorage } from "../adapters/_defaultStorage";
+import { ArtifactCategoriesContext } from "./ArtifactCategoriesContext";
+import {
+ ArtifactRenderersContext,
+ buildArtifactRendererRegistry,
+} from "./ArtifactRenderersContext";
+import { ArtifactStorageContext } from "./ArtifactStorageContext";
import { ChatContext } from "./ChatContext";
-import { createArtifactStore } from "./createArtifactStore";
import { createChatStore } from "./createChatStore";
+import { createDetailedViewStore } from "./createDetailedViewStore";
+import { createThreadContextStore } from "./createThreadContextStore";
+import { DetailedViewContext } from "./DetailedViewContext";
+import { ThreadContextContext } from "./ThreadContextContext";
import type { ChatProviderProps } from "./types";
-export const ChatProvider: FC = ({ children, ...config }) => {
- const [chatStore] = useState(() => createChatStore(config));
- const [artifactStore] = useState(() => createArtifactStore());
+const EMPTY_CATEGORIES: never[] = [];
- // Cross-store subscription: reset artifacts when the active thread changes.
+export const ChatProvider: FC = ({
+ children,
+ storage,
+ llm,
+ artifactRenderers,
+ artifactCategories,
+}) => {
+ const [resolvedStorage] = useState(() => storage ?? createDefaultInMemoryStorage());
+ const [chatStore] = useState(() => createChatStore({ storage: resolvedStorage, llm }));
+ const [detailedViewStore] = useState(() => createDetailedViewStore());
+ const [threadContextStore] = useState(() => createThreadContextStore());
+ const [artifactRendererRegistry] = useState(() =>
+ buildArtifactRendererRegistry(artifactRenderers ?? []),
+ );
+
+ // Dev-mode warning if artifactRenderers reference changes after mount —
+ // captured registry is mount-only, so changes are silently ignored otherwise.
+ const initialRenderersRef = useRef(artifactRenderers);
+ const hasWarnedRef = useRef(false);
+ useEffect(() => {
+ if (
+ typeof process !== "undefined" &&
+ process.env?.["NODE_ENV"] !== "production" &&
+ !hasWarnedRef.current &&
+ initialRenderersRef.current !== artifactRenderers
+ ) {
+ console.warn(
+ "[OpenUI] `artifactRenderers` prop changed after ChatProvider mount. " +
+ "The original array is kept; new renderers will not be registered. " +
+ "Memoize the array (useMemo) to avoid this warning.",
+ );
+ hasWarnedRef.current = true;
+ }
+ }, [artifactRenderers]);
+
+ // Cross-store subscription: reset detailed-view + thread-context state when the active thread changes.
// useEffect (not inline) so the cleanup function unsubscribes on unmount.
useEffect(() => {
const unsubscribe = chatStore.subscribe(
(state) => state.selectedThreadId,
- () => artifactStore.getState().resetArtifacts(),
+ () => {
+ detailedViewStore.getState().reset();
+ threadContextStore.getState().reset();
+ },
);
return unsubscribe;
- }, [chatStore, artifactStore]);
+ }, [chatStore, detailedViewStore, threadContextStore]);
return (
- {children}
+
+
+
+
+
+ {children}
+
+
+
+
+
);
};
diff --git a/packages/react-headless/src/store/DetailedViewContext.ts b/packages/react-headless/src/store/DetailedViewContext.ts
new file mode 100644
index 000000000..553e53863
--- /dev/null
+++ b/packages/react-headless/src/store/DetailedViewContext.ts
@@ -0,0 +1,24 @@
+import { createContext, useContext } from "react";
+import type { StoreApi } from "zustand";
+import type { DetailedViewStore } from "./detailedViewTypes";
+
+/** @internal React context holding the detailed-view Zustand store. Provided by `ChatProvider`. */
+export const DetailedViewContext = createContext | null>(null);
+
+/**
+ * Returns the raw detailed-view Zustand store for advanced use cases.
+ *
+ * Prefer {@link useDetailedView} or {@link useActiveDetailedView} for most cases —
+ * this hook is an escape hatch when you need direct store access.
+ *
+ * @category Hooks
+ * @returns The Zustand `StoreApi` instance
+ * @throws Error if called outside a ``
+ */
+export const useDetailedViewStore = (): StoreApi => {
+ const store = useContext(DetailedViewContext);
+ if (!store) {
+ throw new Error("useDetailedViewStore must be used within a ");
+ }
+ return store;
+};
diff --git a/packages/react-headless/src/store/ThreadContextContext.ts b/packages/react-headless/src/store/ThreadContextContext.ts
new file mode 100644
index 000000000..cd0718c18
--- /dev/null
+++ b/packages/react-headless/src/store/ThreadContextContext.ts
@@ -0,0 +1,25 @@
+import { createContext, useContext } from "react";
+import type { StoreApi } from "zustand";
+import type { ThreadContextStore } from "./threadContextTypes";
+
+/** @internal React context holding the ThreadContext Zustand store. Provided by `ChatProvider`. */
+export const ThreadContextContext = createContext | null>(null);
+
+/**
+ * Returns the raw ThreadContext Zustand store for advanced use cases.
+ *
+ * Prefer {@link useDetailedView}, {@link useActiveDetailedView}, {@link useAppList},
+ * or {@link useArtifactList} for most cases — this hook is an escape hatch when you
+ * need direct store access.
+ *
+ * @category Hooks
+ * @returns The Zustand `StoreApi` instance
+ * @throws Error if called outside a ``
+ */
+export const useThreadContextStore = (): StoreApi => {
+ const store = useContext(ThreadContextContext);
+ if (!store) {
+ throw new Error("useThreadContextStore must be used within a ");
+ }
+ return store;
+};
diff --git a/packages/react-headless/src/store/__tests__/__helpers/makeStore.ts b/packages/react-headless/src/store/__tests__/__helpers/makeStore.ts
new file mode 100644
index 000000000..4f08e1e86
--- /dev/null
+++ b/packages/react-headless/src/store/__tests__/__helpers/makeStore.ts
@@ -0,0 +1,39 @@
+import { vi } from "vitest";
+import type { ChatLLM, ChatStorage, ThreadStorage } from "../../../adapters/types";
+import { createChatStore } from "../../createChatStore";
+
+export interface MakeStoreOverrides extends Partial {
+ send?: ChatLLM["send"];
+ streamProtocol?: ChatLLM["streamProtocol"];
+}
+
+/**
+ * Build a chat store with mocked storage + LLM adapters. Per-field overrides
+ * for any storage method or `llm.send`/`llm.streamProtocol`. Anything not
+ * overridden gets a vi.fn() mock with a sensible default return.
+ */
+export function makeStore(overrides: MakeStoreOverrides = {}) {
+ const { send, streamProtocol, ...threadOverrides } = overrides;
+
+ const storage: ChatStorage = {
+ thread: {
+ listThreads: vi.fn().mockResolvedValue({ threads: [] }),
+ createThread: vi.fn().mockResolvedValue({
+ id: "new",
+ title: "New",
+ createdAt: new Date().toISOString(),
+ }),
+ getMessages: vi.fn().mockResolvedValue([]),
+ updateThread: vi.fn(async (t) => t),
+ deleteThread: vi.fn().mockResolvedValue(undefined),
+ ...threadOverrides,
+ },
+ };
+
+ const llm: ChatLLM = {
+ send: send ?? vi.fn().mockResolvedValue(new Response("", { status: 200 })),
+ streamProtocol: streamProtocol ?? { parse: async function* () {} },
+ };
+
+ return createChatStore({ storage, llm });
+}
diff --git a/packages/react-headless/src/store/__tests__/artifactRendererRegistry.test.ts b/packages/react-headless/src/store/__tests__/artifactRendererRegistry.test.ts
new file mode 100644
index 000000000..1b9c1a541
--- /dev/null
+++ b/packages/react-headless/src/store/__tests__/artifactRendererRegistry.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ buildArtifactRendererRegistry,
+ lookupArtifactRenderer,
+ lookupArtifactRendererByType,
+} from "../ArtifactRenderersContext";
+import { defineArtifactRenderer } from "../artifactRendererTypes";
+
+const makeRenderer = (type: string, toolName: string | string[]) =>
+ defineArtifactRenderer({
+ type,
+ toolName,
+ parser: () => ({ props: {}, meta: null }),
+ preview: () => null,
+ actual: () => null,
+ });
+
+describe("buildArtifactRendererRegistry", () => {
+ it("indexes a single toolName", () => {
+ const r = makeRenderer("th_presentation", "presentation:create");
+ const registry = buildArtifactRendererRegistry([r]);
+ expect(registry.byToolName.get("presentation:create")).toBe(r);
+ });
+
+ it("indexes every name of a toolName array under the same renderer", () => {
+ const r = makeRenderer("th_presentation", ["presentation:create", "presentation:edit"]);
+ const registry = buildArtifactRendererRegistry([r]);
+ expect(registry.byToolName.get("presentation:create")).toBe(r);
+ expect(registry.byToolName.get("presentation:edit")).toBe(r);
+ });
+
+ it("indexes by type", () => {
+ const r = makeRenderer("th_dashboard", "dashboard:create");
+ const registry = buildArtifactRendererRegistry([r]);
+ expect(registry.byType.get("th_dashboard")).toBe(r);
+ });
+
+ it("first-wins on duplicate toolName with dev warning", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const first = makeRenderer("a", "tool:x");
+ const second = makeRenderer("b", "tool:x");
+ const registry = buildArtifactRendererRegistry([first, second]);
+ expect(registry.byToolName.get("tool:x")).toBe(first);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('toolName "tool:x"'));
+ warn.mockRestore();
+ });
+
+ it("first-wins on duplicate type with dev warning", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const first = makeRenderer("th_report", "report:create");
+ const second = makeRenderer("th_report", "report:edit");
+ const registry = buildArtifactRendererRegistry([first, second]);
+ expect(registry.byType.get("th_report")).toBe(first);
+ // second's toolName still registers (different name), only type lookup is deduped
+ expect(registry.byToolName.get("report:edit")).toBe(second);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('type "th_report"'));
+ warn.mockRestore();
+ });
+});
+
+describe("lookupArtifactRenderer", () => {
+ it("resolves by toolName and returns null on miss", () => {
+ const r = makeRenderer("th_presentation", ["p:create", "p:edit"]);
+ const registry = buildArtifactRendererRegistry([r]);
+ expect(lookupArtifactRenderer(registry, "p:edit")).toBe(r);
+ expect(lookupArtifactRenderer(registry, "unknown")).toBeNull();
+ });
+});
+
+describe("lookupArtifactRendererByType", () => {
+ it("resolves by type and returns null on miss", () => {
+ const r = makeRenderer("th_dashboard", "d:create");
+ const registry = buildArtifactRendererRegistry([r]);
+ expect(lookupArtifactRendererByType(registry, "th_dashboard")).toBe(r);
+ expect(lookupArtifactRendererByType(registry, "th_other")).toBeNull();
+ });
+});
diff --git a/packages/react-headless/src/store/__tests__/artifactThreadSwitch.test.ts b/packages/react-headless/src/store/__tests__/artifactThreadSwitch.test.ts
deleted file mode 100644
index 56ebf2487..000000000
--- a/packages/react-headless/src/store/__tests__/artifactThreadSwitch.test.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createArtifactStore } from "../createArtifactStore";
-import { createChatStore } from "../createChatStore";
-
-const flushPromises = () => new Promise((r) => setTimeout(r, 0));
-
-describe("artifact thread-switch cleanup", () => {
- const setupStores = () => {
- const chatStore = createChatStore({ processMessage: vi.fn() });
- const artifactStore = createArtifactStore();
-
- const unsubscribe = chatStore.subscribe(
- (state) => state.selectedThreadId,
- () => artifactStore.getState().resetArtifacts(),
- );
-
- return { chatStore, artifactStore, unsubscribe };
- };
-
- it("clears active artifact when selectThread is called", async () => {
- const { chatStore, artifactStore, unsubscribe } = setupStores();
-
- artifactStore.getState().openArtifact("art-1");
- expect(artifactStore.getState().activeArtifactId).toBe("art-1");
-
- chatStore.getState().selectThread("thread-2");
- await flushPromises();
-
- expect(artifactStore.getState().activeArtifactId).toBeNull();
-
- unsubscribe();
- });
-
- it("clears active artifact when switchToNewThread is called", async () => {
- const { chatStore, artifactStore, unsubscribe } = setupStores();
-
- chatStore.setState({ selectedThreadId: "thread-1" });
- artifactStore.getState().openArtifact("art-1");
-
- chatStore.getState().switchToNewThread();
- await flushPromises();
-
- expect(artifactStore.getState().activeArtifactId).toBeNull();
-
- unsubscribe();
- });
-
- it("clears active artifact when active thread is deleted", async () => {
- const deleteThread = vi.fn().mockResolvedValue(undefined);
- const chatStore = createChatStore({ deleteThread, processMessage: vi.fn() });
- const artifactStore = createArtifactStore();
-
- const unsubscribe = chatStore.subscribe(
- (state) => state.selectedThreadId,
- () => artifactStore.getState().resetArtifacts(),
- );
-
- chatStore.setState({
- selectedThreadId: "thread-1",
- threads: [
- {
- id: "thread-1",
- title: "Test",
- createdAt: new Date().toISOString(),
- },
- ],
- });
-
- artifactStore.getState().openArtifact("art-1");
-
- chatStore.getState().deleteThread("thread-1");
- await flushPromises();
-
- expect(artifactStore.getState().activeArtifactId).toBeNull();
-
- unsubscribe();
- });
-
- it("does not clear active artifact when re-selecting the same thread", async () => {
- const { chatStore, artifactStore, unsubscribe } = setupStores();
-
- chatStore.setState({ selectedThreadId: "thread-1" });
- await flushPromises();
-
- artifactStore.getState().openArtifact("art-1");
- expect(artifactStore.getState().activeArtifactId).toBe("art-1");
-
- chatStore.getState().selectThread("thread-1");
- await flushPromises();
-
- expect(artifactStore.getState().activeArtifactId).toBe("art-1");
-
- unsubscribe();
- });
-
- it("handles rapid thread switches cleanly", async () => {
- const loadThread = vi.fn().mockResolvedValue([]);
- const chatStore = createChatStore({ loadThread, processMessage: vi.fn() });
- const artifactStore = createArtifactStore();
-
- const unsubscribe = chatStore.subscribe(
- (state) => state.selectedThreadId,
- () => artifactStore.getState().resetArtifacts(),
- );
-
- artifactStore.getState().openArtifact("art-1");
-
- chatStore.getState().selectThread("thread-1");
- chatStore.getState().selectThread("thread-2");
- chatStore.getState().selectThread("thread-3");
- await flushPromises();
-
- expect(artifactStore.getState().activeArtifactId).toBeNull();
- expect(chatStore.getState().selectedThreadId).toBe("thread-3");
-
- unsubscribe();
- });
-});
diff --git a/packages/react-headless/src/store/__tests__/createArtifactStore.test.ts b/packages/react-headless/src/store/__tests__/createArtifactStore.test.ts
deleted file mode 100644
index 40bf14379..000000000
--- a/packages/react-headless/src/store/__tests__/createArtifactStore.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { createArtifactStore } from "../createArtifactStore";
-
-describe("createArtifactStore", () => {
- it("has correct initial state", () => {
- const store = createArtifactStore();
- const state = store.getState();
-
- expect(state.activeArtifactId).toBeNull();
- expect(state._artifactPanelNode).toBeNull();
- });
-
- describe("openArtifact", () => {
- it("sets activeArtifactId", () => {
- const store = createArtifactStore();
-
- store.getState().openArtifact("art-1");
-
- expect(store.getState().activeArtifactId).toBe("art-1");
- });
-
- it("replaces active artifact when opening a different one", () => {
- const store = createArtifactStore();
-
- store.getState().openArtifact("art-1");
- store.getState().openArtifact("art-2");
-
- expect(store.getState().activeArtifactId).toBe("art-2");
- });
-
- it("is idempotent when opening the same artifact", () => {
- const store = createArtifactStore();
-
- store.getState().openArtifact("art-1");
- store.getState().openArtifact("art-1");
-
- expect(store.getState().activeArtifactId).toBe("art-1");
- });
- });
-
- describe("closeArtifact", () => {
- it("clears activeArtifactId when closing the active artifact", () => {
- const store = createArtifactStore();
-
- store.getState().openArtifact("art-1");
- expect(store.getState().activeArtifactId).toBe("art-1");
-
- store.getState().closeArtifact("art-1");
- expect(store.getState().activeArtifactId).toBeNull();
- });
-
- it("no-ops when closing a non-active artifact", () => {
- const store = createArtifactStore();
-
- store.getState().openArtifact("art-1");
- store.getState().closeArtifact("art-2");
-
- expect(store.getState().activeArtifactId).toBe("art-1");
- });
-
- it("no-ops when nothing is active", () => {
- const store = createArtifactStore();
-
- store.getState().closeArtifact("art-1");
-
- expect(store.getState().activeArtifactId).toBeNull();
- });
- });
-
- describe("resetArtifacts", () => {
- it("resets activeArtifactId to null", () => {
- const store = createArtifactStore();
-
- store.getState().openArtifact("art-1");
- expect(store.getState().activeArtifactId).toBe("art-1");
-
- store.getState().resetArtifacts();
- expect(store.getState().activeArtifactId).toBeNull();
- });
- });
-
- describe("_setArtifactPanelNode", () => {
- it("sets and clears DOM reference", () => {
- const store = createArtifactStore();
-
- const node = {} as HTMLElement;
- store.getState()._setArtifactPanelNode(node);
- expect(store.getState()._artifactPanelNode).toBe(node);
-
- store.getState()._setArtifactPanelNode(null);
- expect(store.getState()._artifactPanelNode).toBeNull();
- });
- });
-});
diff --git a/packages/react-headless/src/store/__tests__/createChatStore.test.ts b/packages/react-headless/src/store/__tests__/createChatStore.test.ts
index c50d0dd1c..1d28dfbab 100644
--- a/packages/react-headless/src/store/__tests__/createChatStore.test.ts
+++ b/packages/react-headless/src/store/__tests__/createChatStore.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { createChatStore } from "../createChatStore";
import type { Message, Thread, UserMessage } from "../types";
+import { makeStore } from "./__helpers/makeStore";
// ── Helpers ──
@@ -25,9 +25,9 @@ describe("createChatStore", () => {
describe("loadThreads", () => {
it("fetches threads and sets state", async () => {
const threads = [makeThread("t1"), makeThread("t2", 1)];
- const fetchThreadList = vi.fn().mockResolvedValue({ threads });
+ const listThreads = vi.fn().mockResolvedValue({ threads });
- const store = createChatStore({ fetchThreadList, processMessage: vi.fn() });
+ const store = makeStore({ listThreads });
expect(store.getState().isLoadingThreads).toBe(false);
store.getState().loadThreads();
@@ -38,14 +38,14 @@ describe("createChatStore", () => {
expect(store.getState().isLoadingThreads).toBe(false);
expect(store.getState().threads).toHaveLength(2);
expect(store.getState().hasMoreThreads).toBe(false);
- expect(fetchThreadList).toHaveBeenCalledWith(undefined);
+ expect(listThreads).toHaveBeenCalledWith(undefined);
});
it("sets threadListError on failure", async () => {
const error = new Error("network");
- const fetchThreadList = vi.fn().mockRejectedValue(error);
+ const listThreads = vi.fn().mockRejectedValue(error);
- const store = createChatStore({ fetchThreadList, processMessage: vi.fn() });
+ const store = makeStore({ listThreads });
store.getState().loadThreads();
await flushPromises();
@@ -54,17 +54,17 @@ describe("createChatStore", () => {
});
it("handles pagination cursor", async () => {
- const fetchThreadList = vi.fn().mockResolvedValue({
+ const listThreads = vi.fn().mockResolvedValue({
threads: [makeThread("t1")],
nextCursor: "page2",
});
- const store = createChatStore({ fetchThreadList, processMessage: vi.fn() });
+ const store = makeStore({ listThreads });
store.getState().loadThreads();
await flushPromises();
expect(store.getState().hasMoreThreads).toBe(true);
- expect(fetchThreadList).toHaveBeenCalledWith(undefined);
+ expect(listThreads).toHaveBeenCalledWith(undefined);
});
});
@@ -72,12 +72,12 @@ describe("createChatStore", () => {
it("appends threads using cursor", async () => {
const page1 = [makeThread("t1")];
const page2 = [makeThread("t2", 1)];
- const fetchThreadList = vi
+ const listThreads = vi
.fn()
.mockResolvedValueOnce({ threads: page1, nextCursor: "c2" })
.mockResolvedValueOnce({ threads: page2 });
- const store = createChatStore({ fetchThreadList, processMessage: vi.fn() });
+ const store = makeStore({ listThreads });
store.getState().loadThreads();
await flushPromises();
@@ -88,29 +88,29 @@ describe("createChatStore", () => {
expect(store.getState().threads).toHaveLength(2);
expect(store.getState().hasMoreThreads).toBe(false);
- expect(fetchThreadList).toHaveBeenCalledWith("c2");
+ expect(listThreads).toHaveBeenCalledWith("c2");
});
it("no-ops when no more pages", async () => {
- const fetchThreadList = vi.fn().mockResolvedValue({ threads: [makeThread("t1")] });
+ const listThreads = vi.fn().mockResolvedValue({ threads: [makeThread("t1")] });
- const store = createChatStore({ fetchThreadList, processMessage: vi.fn() });
+ const store = makeStore({ listThreads });
store.getState().loadThreads();
await flushPromises();
store.getState().loadMoreThreads();
await flushPromises();
- expect(fetchThreadList).toHaveBeenCalledTimes(1);
+ expect(listThreads).toHaveBeenCalledTimes(1);
});
});
describe("selectThread", () => {
it("sets selectedThreadId, loads messages, clears previous", async () => {
const messages: Message[] = [makeMessage("m1"), makeMessage("m2", "assistant")];
- const loadThread = vi.fn().mockResolvedValue(messages);
+ const getMessages = vi.fn().mockResolvedValue(messages);
- const store = createChatStore({ loadThread, processMessage: vi.fn() });
+ const store = makeStore({ getMessages });
store.setState({ messages: [makeMessage("old")] });
@@ -124,14 +124,14 @@ describe("createChatStore", () => {
expect(store.getState().messages).toEqual(messages);
expect(store.getState().isLoadingMessages).toBe(false);
- expect(loadThread).toHaveBeenCalledWith("t1");
+ expect(getMessages).toHaveBeenCalledWith("t1");
});
it("sets threadError on load failure", async () => {
const error = new Error("load failed");
- const loadThread = vi.fn().mockRejectedValue(error);
+ const getMessages = vi.fn().mockRejectedValue(error);
- const store = createChatStore({ loadThread, processMessage: vi.fn() });
+ const store = makeStore({ getMessages });
store.getState().selectThread("t1");
await flushPromises();
@@ -142,7 +142,7 @@ describe("createChatStore", () => {
describe("switchToNewThread", () => {
it("clears selection, messages, and errors", () => {
- const store = createChatStore({ processMessage: vi.fn() });
+ const store = makeStore();
store.setState({
selectedThreadId: "t1",
@@ -163,7 +163,7 @@ describe("createChatStore", () => {
const newThread = makeThread("t-new");
const createThread = vi.fn().mockResolvedValue(newThread);
- const store = createChatStore({ createThread, processMessage: vi.fn() });
+ const store = makeStore({ createThread });
store.setState({ threads: [makeThread("t-existing")] });
const result = await store.getState().createThread({
@@ -181,7 +181,7 @@ describe("createChatStore", () => {
describe("deleteThread", () => {
it("removes thread from list", async () => {
const deleteThread = vi.fn().mockResolvedValue(undefined);
- const store = createChatStore({ deleteThread, processMessage: vi.fn() });
+ const store = makeStore({ deleteThread });
store.setState({ threads: [makeThread("t1"), makeThread("t2", 1)] });
@@ -194,7 +194,7 @@ describe("createChatStore", () => {
it("switches to new thread if deleted thread was selected", async () => {
const deleteThread = vi.fn().mockResolvedValue(undefined);
- const store = createChatStore({ deleteThread, processMessage: vi.fn() });
+ const store = makeStore({ deleteThread });
store.setState({
threads: [makeThread("t1")],
@@ -218,7 +218,7 @@ describe("createChatStore", () => {
}),
);
- const store = createChatStore({ deleteThread, processMessage: vi.fn() });
+ const store = makeStore({ deleteThread });
store.setState({ threads: [makeThread("t1")] });
store.getState().deleteThread("t1");
@@ -237,7 +237,7 @@ describe("createChatStore", () => {
const updated = { ...makeThread("t1"), title: "Renamed" };
const updateThread = vi.fn().mockResolvedValue(updated);
- const store = createChatStore({ updateThread, processMessage: vi.fn() });
+ const store = makeStore({ updateThread });
store.setState({ threads: [makeThread("t1")] });
store.getState().updateThread(updated);
@@ -252,10 +252,10 @@ describe("createChatStore", () => {
// ────────────────────────────────────────────
describe("message CRUD", () => {
- let store: ReturnType;
+ let store: ReturnType;
beforeEach(() => {
- store = createChatStore({ processMessage: vi.fn() });
+ store = makeStore();
store.setState({ messages: [makeMessage("m1"), makeMessage("m2", "assistant")] });
});
@@ -285,15 +285,15 @@ describe("createChatStore", () => {
});
// ────────────────────────────────────────────
- // processMessage
+ // processMessage (calls llm.send)
// ────────────────────────────────────────────
describe("processMessage", () => {
- it("appends optimistic user message and calls processMessage", async () => {
- const processMessage = vi.fn().mockResolvedValue(new Response("", { status: 200 }));
+ it("appends optimistic user message and calls llm.send", async () => {
+ const send = vi.fn().mockResolvedValue(new Response("", { status: 200 }));
- const store = createChatStore({
- processMessage,
+ const store = makeStore({
+ send,
streamProtocol: { parse: async function* () {} },
});
@@ -304,17 +304,17 @@ describe("createChatStore", () => {
expect(store.getState().messages).toHaveLength(1);
expect(store.getState().messages[0].role).toBe("user");
expect(store.getState().isRunning).toBe(false);
- expect(processMessage).toHaveBeenCalledOnce();
+ expect(send).toHaveBeenCalledOnce();
});
it("creates thread when none selected", async () => {
const newThread = makeThread("t-auto");
const createThread = vi.fn().mockResolvedValue(newThread);
- const processMessage = vi.fn().mockResolvedValue(new Response("", { status: 200 }));
+ const send = vi.fn().mockResolvedValue(new Response("", { status: 200 }));
- const store = createChatStore({
+ const store = makeStore({
createThread,
- processMessage,
+ send,
streamProtocol: { parse: async function* () {} },
});
@@ -325,24 +325,24 @@ describe("createChatStore", () => {
});
it("no-ops when already running", async () => {
- const processMessage = vi.fn().mockResolvedValue(new Response("", { status: 200 }));
+ const send = vi.fn().mockResolvedValue(new Response("", { status: 200 }));
- const store = createChatStore({
- processMessage,
+ const store = makeStore({
+ send,
streamProtocol: { parse: async function* () {} },
});
store.setState({ isRunning: true, selectedThreadId: "t1" });
await store.getState().processMessage({ role: "user", content: "hello" });
- expect(processMessage).not.toHaveBeenCalled();
+ expect(send).not.toHaveBeenCalled();
});
it("sets threadError on failure", async () => {
- const processMessage = vi.fn().mockRejectedValue(new Error("api down"));
+ const send = vi.fn().mockRejectedValue(new Error("api down"));
- const store = createChatStore({
- processMessage,
+ const store = makeStore({
+ send,
streamProtocol: { parse: async function* () {} },
});
store.setState({ selectedThreadId: "t1" });
@@ -361,14 +361,14 @@ describe("createChatStore", () => {
describe("cancelMessage", () => {
it("aborts in-flight request", async () => {
- let capturedAbort: AbortController;
- const processMessage = vi.fn().mockImplementation(({ abortController }) => {
- capturedAbort = abortController;
+ let capturedSignal: AbortSignal;
+ const send = vi.fn().mockImplementation(({ signal }) => {
+ capturedSignal = signal;
return new Promise(() => {}); // never resolves
});
- const store = createChatStore({
- processMessage,
+ const store = makeStore({
+ send,
streamProtocol: { parse: async function* () {} },
});
store.setState({ selectedThreadId: "t1" });
@@ -382,415 +382,7 @@ describe("createChatStore", () => {
await flushPromises();
expect(store.getState().isRunning).toBe(false);
- expect(capturedAbort!.signal.aborted).toBe(true);
- });
- });
-
- // ────────────────────────────────────────────
- // apiUrl (default fetch-based processMessage)
- // ────────────────────────────────────────────
-
- describe("apiUrl", () => {
- let fetchSpy: ReturnType;
-
- beforeEach(() => {
- fetchSpy = vi.fn();
- vi.stubGlobal("fetch", fetchSpy);
- });
-
- it("sends POST to apiUrl with threadId and messages", async () => {
- const sseBody = `data: ${JSON.stringify({ type: "TEXT_MESSAGE_CONTENT", delta: "hi" })}\n\ndata: [DONE]\n\n`;
- const stream = new ReadableStream({
- start(c) {
- c.enqueue(new TextEncoder().encode(sseBody));
- c.close();
- },
- });
- fetchSpy.mockResolvedValue(new Response(stream));
-
- const store = createChatStore({ apiUrl: "/api/chat" });
- store.setState({ selectedThreadId: "t1" });
-
- await store.getState().processMessage({ role: "user", content: "hello" });
-
- expect(fetchSpy).toHaveBeenCalledOnce();
- const [url, opts] = fetchSpy.mock.calls[0];
- expect(url).toBe("/api/chat");
- expect(opts.method).toBe("POST");
-
- const body = JSON.parse(opts.body);
- expect(body.threadId).toBe("t1");
- expect(body.messages).toHaveLength(1);
- expect(body.messages[0].role).toBe("user");
- });
-
- it("passes abortController.signal to fetch", async () => {
- fetchSpy.mockImplementation(() => new Promise(() => {}));
-
- const store = createChatStore({ apiUrl: "/api/chat" });
- store.setState({ selectedThreadId: "t1" });
-
- store.getState().processMessage({ role: "user", content: "hi" });
- await flushPromises();
-
- const [, opts] = fetchSpy.mock.calls[0];
- expect(opts.signal).toBeInstanceOf(AbortSignal);
- });
-
- it("streams response via processStreamedMessage", async () => {
- const sseBody = `data: ${JSON.stringify({ type: "TEXT_MESSAGE_CONTENT", delta: "response text" })}\n\ndata: [DONE]\n\n`;
- const stream = new ReadableStream({
- start(c) {
- c.enqueue(new TextEncoder().encode(sseBody));
- c.close();
- },
- });
- fetchSpy.mockResolvedValue(new Response(stream));
-
- const store = createChatStore({ apiUrl: "/api/chat" });
- store.setState({ selectedThreadId: "t1" });
-
- await store.getState().processMessage({ role: "user", content: "hello" });
-
- expect(store.getState().messages).toHaveLength(2);
- expect(store.getState().messages[0].role).toBe("user");
- expect(store.getState().messages[1].role).toBe("assistant");
- expect((store.getState().messages[1] as any).content).toBe("response text");
- });
-
- it("throws when neither apiUrl nor processMessage provided", async () => {
- const store = createChatStore({});
- store.setState({ selectedThreadId: "t1" });
-
- await store.getState().processMessage({ role: "user", content: "hello" });
-
- expect(store.getState().threadError).toBeInstanceOf(Error);
- expect(store.getState().threadError?.message).toContain("apiUrl or processMessage required");
- });
-
- it("applies messageFormat.toApi to outbound messages", async () => {
- const sseBody = `data: [DONE]\n\n`;
- const stream = new ReadableStream({
- start(c) {
- c.enqueue(new TextEncoder().encode(sseBody));
- c.close();
- },
- });
- fetchSpy.mockResolvedValue(new Response(stream));
-
- const toApi = vi.fn((msgs: Message[]) => msgs.map((m) => ({ custom: m.id })));
-
- const store = createChatStore({
- apiUrl: "/api/chat",
- messageFormat: { toApi, fromApi: (d) => d as Message[] },
- });
- store.setState({ selectedThreadId: "t1" });
-
- await store.getState().processMessage({ role: "user", content: "hi" });
-
- const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
- expect(body.messages[0]).toHaveProperty("custom");
- expect(toApi).toHaveBeenCalled();
- });
-
- it("uses ephemeral threadId when no thread selected and no createThread", async () => {
- const sseBody = `data: [DONE]\n\n`;
- const stream = new ReadableStream({
- start(c) {
- c.enqueue(new TextEncoder().encode(sseBody));
- c.close();
- },
- });
- fetchSpy.mockResolvedValue(new Response(stream));
-
- const store = createChatStore({ apiUrl: "/api/chat" });
-
- await store.getState().processMessage({ role: "user", content: "hello" });
-
- const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
- expect(body.threadId).toBe("ephemeral");
- });
- });
-
- // ────────────────────────────────────────────
- // threadApiUrl (default fetch-based thread ops)
- // ────────────────────────────────────────────
-
- describe("threadApiUrl", () => {
- let fetchSpy: ReturnType;
-
- beforeEach(() => {
- fetchSpy = vi.fn();
- vi.stubGlobal("fetch", fetchSpy);
- });
-
- it("loadThreads fetches from threadApiUrl/get", async () => {
- const threads = [makeThread("t1"), makeThread("t2", 1)];
- fetchSpy.mockResolvedValue(new Response(JSON.stringify({ threads })));
-
- const store = createChatStore({ threadApiUrl: "/api/threads", apiUrl: "/api/chat" });
- store.getState().loadThreads();
- await flushPromises();
-
- expect(fetchSpy).toHaveBeenCalledWith("/api/threads/get");
- expect(store.getState().threads).toHaveLength(2);
- });
-
- it("loadMoreThreads passes cursor as query param", async () => {
- fetchSpy
- .mockResolvedValueOnce(
- new Response(JSON.stringify({ threads: [makeThread("t1")], nextCursor: "abc" })),
- )
- .mockResolvedValueOnce(new Response(JSON.stringify({ threads: [makeThread("t2", 1)] })));
-
- const store = createChatStore({ threadApiUrl: "/api/threads", apiUrl: "/api/chat" });
- store.getState().loadThreads();
- await flushPromises();
-
- store.getState().loadMoreThreads();
- await flushPromises();
-
- expect(fetchSpy).toHaveBeenCalledWith("/api/threads/get?cursor=abc");
- expect(store.getState().threads).toHaveLength(2);
- });
-
- it("createThread POSTs to threadApiUrl/create", async () => {
- const thread = makeThread("t-new");
- fetchSpy.mockResolvedValue(new Response(JSON.stringify(thread)));
-
- const store = createChatStore({ threadApiUrl: "/api/threads", apiUrl: "/api/chat" });
-
- const result = await store.getState().createThread({
- id: "m1",
- role: "user",
- content: "hello",
- } as UserMessage);
-
- const [url, opts] = fetchSpy.mock.calls[0];
- expect(url).toBe("/api/threads/create");
- expect(opts.method).toBe("POST");
- expect(JSON.parse(opts.body)).toHaveProperty("messages");
- expect(result.id).toBe("t-new");
- });
-
- it("deleteThread DELETEs threadApiUrl/delete/:id", async () => {
- fetchSpy.mockResolvedValue(new Response(""));
-
- const store = createChatStore({ threadApiUrl: "/api/threads", apiUrl: "/api/chat" });
- store.setState({ threads: [makeThread("t1")] });
-
- store.getState().deleteThread("t1");
- await flushPromises();
-
- expect(fetchSpy).toHaveBeenCalledWith("/api/threads/delete/t1", { method: "DELETE" });
- expect(store.getState().threads).toHaveLength(0);
- });
-
- it("updateThread PATCHes threadApiUrl/update/:id", async () => {
- const updated = { ...makeThread("t1"), title: "Renamed" };
- fetchSpy.mockResolvedValue(new Response(JSON.stringify(updated)));
-
- const store = createChatStore({ threadApiUrl: "/api/threads", apiUrl: "/api/chat" });
- store.setState({ threads: [makeThread("t1")] });
-
- store.getState().updateThread(updated);
- await flushPromises();
-
- const [url, opts] = fetchSpy.mock.calls[0];
- expect(url).toBe("/api/threads/update/t1");
- expect(opts.method).toBe("PATCH");
- expect(store.getState().threads[0]!.title).toBe("Renamed");
- });
-
- it("selectThread GETs threadApiUrl/get/:id and applies messageFormat.fromApi", async () => {
- const rawMessages = [{ custom: "data" }];
- const parsed: Message[] = [makeMessage("m1")];
- const fromApi = vi.fn().mockReturnValue(parsed);
-
- fetchSpy.mockResolvedValue(new Response(JSON.stringify(rawMessages)));
-
- const store = createChatStore({
- threadApiUrl: "/api/threads",
- apiUrl: "/api/chat",
- messageFormat: { toApi: (m) => m, fromApi },
- });
-
- store.getState().selectThread("t1");
- await flushPromises();
-
- expect(fetchSpy).toHaveBeenCalledWith("/api/threads/get/t1");
- expect(fromApi).toHaveBeenCalledWith(rawMessages);
- expect(store.getState().messages).toEqual(parsed);
- });
-
- it("processMessage auto-creates thread via threadApiUrl when none selected", async () => {
- const thread = makeThread("t-auto");
- fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(thread))).mockResolvedValueOnce(
- new Response(
- new ReadableStream({
- start(c) {
- c.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
- c.close();
- },
- }),
- ),
- );
-
- const store = createChatStore({ threadApiUrl: "/api/threads", apiUrl: "/api/chat" });
-
- await store.getState().processMessage({ role: "user", content: "hello" });
-
- expect(fetchSpy.mock.calls[0][0]).toBe("/api/threads/create");
- expect(store.getState().selectedThreadId).toBe("t-auto");
- });
- });
-
- // ────────────────────────────────────────────
- // messageFormat round-tripping
- // ────────────────────────────────────────────
-
- describe("messageFormat", () => {
- let fetchSpy: ReturnType;
-
- beforeEach(() => {
- fetchSpy = vi.fn();
- vi.stubGlobal("fetch", fetchSpy);
- });
-
- const doneStream = () =>
- new ReadableStream({
- start(c) {
- c.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
- c.close();
- },
- });
-
- const customFormat = {
- toApi: (msgs: Message[]) => msgs.map((m) => ({ r: m.role, c: (m as any).content })),
- fromApi: (data: unknown) =>
- (data as Array<{ r: string; c: string }>).map((d, i) => ({
- id: `parsed-${i}`,
- role: d.r,
- content: d.c,
- })) as Message[],
- };
-
- it("toApi is applied when sending messages via apiUrl", async () => {
- fetchSpy.mockResolvedValue(new Response(doneStream()));
-
- const toApi = vi.fn((msgs: Message[]) =>
- msgs.map((m) => ({ custom_role: m.role, custom_content: (m as any).content })),
- );
-
- const store = createChatStore({
- apiUrl: "/api/chat",
- messageFormat: { toApi, fromApi: (d) => d as Message[] },
- });
- store.setState({ selectedThreadId: "t1" });
-
- await store.getState().processMessage({ role: "user", content: "hello" });
-
- expect(toApi).toHaveBeenCalled();
- const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
- expect(body.messages[0]).toEqual({ custom_role: "user", custom_content: "hello" });
- expect(body.messages[0]).not.toHaveProperty("id");
- });
-
- it("fromApi is applied when loading thread via threadApiUrl", async () => {
- const serverPayload = [
- { r: "user", c: "hi" },
- { r: "assistant", c: "hello" },
- ];
- fetchSpy.mockResolvedValue(new Response(JSON.stringify(serverPayload)));
-
- const fromApi = vi.fn(customFormat.fromApi);
-
- const store = createChatStore({
- apiUrl: "/api/chat",
- threadApiUrl: "/api/threads",
- messageFormat: { toApi: (m) => m, fromApi },
- });
-
- store.getState().selectThread("t1");
- await flushPromises();
-
- expect(fromApi).toHaveBeenCalledWith(serverPayload);
- expect(store.getState().messages).toHaveLength(2);
- expect(store.getState().messages[0].role).toBe("user");
- expect((store.getState().messages[0] as any).content).toBe("hi");
- expect(store.getState().messages[1].role).toBe("assistant");
- expect((store.getState().messages[1] as any).content).toBe("hello");
- });
-
- it("toApi is applied when creating thread via threadApiUrl", async () => {
- const thread = makeThread("t-new");
- fetchSpy.mockResolvedValue(new Response(JSON.stringify(thread)));
-
- const toApi = vi.fn((msgs: Message[]) =>
- msgs.map((m) => ({ r: m.role, c: (m as any).content })),
- );
-
- const store = createChatStore({
- apiUrl: "/api/chat",
- threadApiUrl: "/api/threads",
- messageFormat: { toApi, fromApi: (d) => d as Message[] },
- });
-
- await store.getState().createThread({
- id: "m1",
- role: "user",
- content: "hello",
- } as UserMessage);
-
- expect(toApi).toHaveBeenCalled();
- const [url, opts] = fetchSpy.mock.calls[0];
- expect(url).toBe("/api/threads/create");
- const body = JSON.parse(opts.body);
- expect(body.messages).toEqual([{ r: "user", c: "hello" }]);
- });
-
- it("round-trip: toApi then fromApi preserves data", async () => {
- const thread = makeThread("t-rt");
-
- fetchSpy
- .mockResolvedValueOnce(new Response(JSON.stringify(thread)))
- .mockResolvedValueOnce(new Response(doneStream()))
- .mockResolvedValueOnce(new Response(JSON.stringify([{ r: "user", c: "round-trip" }])));
-
- const store = createChatStore({
- apiUrl: "/api/chat",
- threadApiUrl: "/api/threads",
- messageFormat: customFormat,
- });
-
- await store.getState().processMessage({ role: "user", content: "round-trip" });
-
- const createBody = JSON.parse(fetchSpy.mock.calls[0][1].body);
- expect(createBody.messages[0]).toEqual({ r: "user", c: "round-trip" });
-
- const sendBody = JSON.parse(fetchSpy.mock.calls[1][1].body);
- expect(sendBody.messages[0]).toEqual({ r: "user", c: "round-trip" });
-
- store.getState().selectThread("t-rt");
- await flushPromises();
-
- expect(store.getState().messages).toHaveLength(1);
- expect(store.getState().messages[0].role).toBe("user");
- expect((store.getState().messages[0] as any).content).toBe("round-trip");
- });
-
- it("default identityMessageFormat passes through unchanged", async () => {
- fetchSpy.mockResolvedValue(new Response(doneStream()));
-
- const store = createChatStore({ apiUrl: "/api/chat" });
- store.setState({ selectedThreadId: "t1" });
-
- await store.getState().processMessage({ role: "user", content: "raw" });
-
- const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
- expect(body.messages[0]).toHaveProperty("id");
- expect(body.messages[0].role).toBe("user");
- expect(body.messages[0].content).toBe("raw");
+ expect(capturedSignal!.aborted).toBe(true);
});
});
@@ -800,17 +392,17 @@ describe("createChatStore", () => {
describe("selectThread while streaming", () => {
it("cancels current stream and loads new thread", async () => {
- let capturedAbort: AbortController;
- const processMessage = vi.fn().mockImplementation(({ abortController }) => {
- capturedAbort = abortController;
+ let capturedSignal: AbortSignal;
+ const send = vi.fn().mockImplementation(({ signal }) => {
+ capturedSignal = signal;
return new Promise(() => {}); // never resolves
});
const newMessages = [makeMessage("new-m1")];
- const loadThread = vi.fn().mockResolvedValue(newMessages);
+ const getMessages = vi.fn().mockResolvedValue(newMessages);
- const store = createChatStore({
- processMessage,
- loadThread,
+ const store = makeStore({
+ send,
+ getMessages,
streamProtocol: { parse: async function* () {} },
});
store.setState({ selectedThreadId: "t1" });
@@ -823,7 +415,7 @@ describe("createChatStore", () => {
// Switch thread mid-stream
store.getState().selectThread("t2");
- expect(capturedAbort!.signal.aborted).toBe(true);
+ expect(capturedSignal!.aborted).toBe(true);
expect(store.getState().selectedThreadId).toBe("t2");
expect(store.getState().isLoadingMessages).toBe(true);
diff --git a/packages/react-headless/src/store/__tests__/createDetailedViewStore.test.ts b/packages/react-headless/src/store/__tests__/createDetailedViewStore.test.ts
new file mode 100644
index 000000000..c07d355a3
--- /dev/null
+++ b/packages/react-headless/src/store/__tests__/createDetailedViewStore.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import { createDetailedViewStore } from "../createDetailedViewStore";
+
+describe("createDetailedViewStore", () => {
+ it("has correct initial state", () => {
+ const store = createDetailedViewStore();
+ const state = store.getState();
+
+ expect(state.activeDetailedViewId).toBeNull();
+ expect(state._detailedViewPanelNode).toBeNull();
+ });
+
+ describe("setActiveDetailedView", () => {
+ it("sets activeDetailedViewId", () => {
+ const store = createDetailedViewStore();
+
+ store.getState().setActiveDetailedView("view-1");
+
+ expect(store.getState().activeDetailedViewId).toBe("view-1");
+ });
+
+ it("replaces active view when called with a different id", () => {
+ const store = createDetailedViewStore();
+
+ store.getState().setActiveDetailedView("view-1");
+ store.getState().setActiveDetailedView("view-2");
+
+ expect(store.getState().activeDetailedViewId).toBe("view-2");
+ });
+
+ it("clears with null", () => {
+ const store = createDetailedViewStore();
+
+ store.getState().setActiveDetailedView("view-1");
+ store.getState().setActiveDetailedView(null);
+
+ expect(store.getState().activeDetailedViewId).toBeNull();
+ });
+ });
+
+ describe("reset", () => {
+ it("resets activeDetailedViewId to null", () => {
+ const store = createDetailedViewStore();
+
+ store.getState().setActiveDetailedView("view-1");
+ expect(store.getState().activeDetailedViewId).toBe("view-1");
+
+ store.getState().reset();
+ expect(store.getState().activeDetailedViewId).toBeNull();
+ });
+ });
+
+ describe("_setDetailedViewPanelNode", () => {
+ it("sets and clears DOM reference", () => {
+ const store = createDetailedViewStore();
+
+ const node = {} as HTMLElement;
+ store.getState()._setDetailedViewPanelNode(node);
+ expect(store.getState()._detailedViewPanelNode).toBe(node);
+
+ store.getState()._setDetailedViewPanelNode(null);
+ expect(store.getState()._detailedViewPanelNode).toBeNull();
+ });
+ });
+});
diff --git a/packages/react-headless/src/store/__tests__/createThreadContextStore.test.ts b/packages/react-headless/src/store/__tests__/createThreadContextStore.test.ts
new file mode 100644
index 000000000..3e07bd0f2
--- /dev/null
+++ b/packages/react-headless/src/store/__tests__/createThreadContextStore.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from "vitest";
+import { createThreadContextStore } from "../createThreadContextStore";
+
+const entry = (id: string, version: number, heading = `H${version}`, type = "th_dashboard") => ({
+ id,
+ version,
+ heading,
+ type,
+});
+
+describe("createThreadContextStore", () => {
+ it("has correct initial state", () => {
+ const store = createThreadContextStore();
+ expect(store.getState().artifacts).toEqual({});
+ });
+
+ describe("registerArtifact", () => {
+ it("adds a new entry", () => {
+ const store = createThreadContextStore();
+ store.getState().registerArtifact(entry("a", 1));
+ expect(store.getState().artifacts["a"]).toEqual([entry("a", 1)]);
+ });
+
+ it("adds multiple versions sorted ascending by version", () => {
+ const store = createThreadContextStore();
+ store.getState().registerArtifact(entry("a", 2));
+ store.getState().registerArtifact(entry("a", 1));
+ store.getState().registerArtifact(entry("a", 3));
+ expect(store.getState().artifacts["a"]!.map((e) => e.version)).toEqual([1, 2, 3]);
+ });
+
+ it("groups separate ids in their own buckets", () => {
+ const store = createThreadContextStore();
+ store.getState().registerArtifact(entry("a", 1));
+ store.getState().registerArtifact(entry("b", 1));
+ expect(Object.keys(store.getState().artifacts)).toEqual(["a", "b"]);
+ });
+
+ it("updates heading when same (id, version) re-registers with different heading", () => {
+ const store = createThreadContextStore();
+ store.getState().registerArtifact(entry("a", 1, "Old"));
+ store.getState().registerArtifact(entry("a", 1, "New"));
+ expect(store.getState().artifacts["a"]).toEqual([entry("a", 1, "New")]);
+ });
+
+ it("updates type when same (id, version) re-registers with different type", () => {
+ const store = createThreadContextStore();
+ store.getState().registerArtifact(entry("a", 1, "H1", "th_dashboard"));
+ store.getState().registerArtifact(entry("a", 1, "H1", "th_presentation"));
+ expect(store.getState().artifacts["a"]![0]!.type).toBe("th_presentation");
+ });
+
+ it("is referentially stable when same (id, version, heading, type) re-registers", () => {
+ const store = createThreadContextStore();
+ store.getState().registerArtifact(entry("a", 1));
+ const before = store.getState().artifacts;
+ store.getState().registerArtifact(entry("a", 1));
+ expect(store.getState().artifacts).toBe(before);
+ });
+ });
+
+ describe("unregisterArtifact", () => {
+ it("removes the matching version", () => {
+ const store = createThreadContextStore();
+ store.getState().registerArtifact(entry("a", 1));
+ store.getState().registerArtifact(entry("a", 2));
+ store.getState().unregisterArtifact("a", 1);
+ expect(store.getState().artifacts["a"]!.map((e) => e.version)).toEqual([2]);
+ });
+
+ it("removes the bucket when last version is removed", () => {
+ const store = createThreadContextStore();
+ store.getState().registerArtifact(entry("a", 1));
+ store.getState().unregisterArtifact("a", 1);
+ expect(store.getState().artifacts).toEqual({});
+ });
+
+ it("is referentially stable when version does not exist", () => {
+ const store = createThreadContextStore();
+ store.getState().registerArtifact(entry("a", 1));
+ const before = store.getState().artifacts;
+ store.getState().unregisterArtifact("a", 99);
+ expect(store.getState().artifacts).toBe(before);
+ });
+
+ it("is referentially stable when id does not exist", () => {
+ const store = createThreadContextStore();
+ const before = store.getState().artifacts;
+ store.getState().unregisterArtifact("missing", 1);
+ expect(store.getState().artifacts).toBe(before);
+ });
+ });
+
+ describe("reset", () => {
+ it("clears the registry", () => {
+ const store = createThreadContextStore();
+ store.getState().registerArtifact(entry("a", 1));
+ store.getState().reset();
+ expect(store.getState().artifacts).toEqual({});
+ });
+ });
+});
diff --git a/packages/react-headless/src/store/__tests__/detailedViewThreadSwitch.test.ts b/packages/react-headless/src/store/__tests__/detailedViewThreadSwitch.test.ts
new file mode 100644
index 000000000..e261af996
--- /dev/null
+++ b/packages/react-headless/src/store/__tests__/detailedViewThreadSwitch.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it, vi } from "vitest";
+import { createDetailedViewStore } from "../createDetailedViewStore";
+import { makeStore } from "./__helpers/makeStore";
+
+const flushPromises = () => new Promise((r) => setTimeout(r, 0));
+
+describe("detailed-view thread-switch cleanup", () => {
+ const setupStores = () => {
+ const chatStore = makeStore();
+ const detailedViewStore = createDetailedViewStore();
+
+ const unsubscribe = chatStore.subscribe(
+ (state) => state.selectedThreadId,
+ () => detailedViewStore.getState().reset(),
+ );
+
+ return { chatStore, detailedViewStore, unsubscribe };
+ };
+
+ it("clears active view when selectThread is called", async () => {
+ const { chatStore, detailedViewStore, unsubscribe } = setupStores();
+
+ detailedViewStore.getState().setActiveDetailedView("view-1");
+ expect(detailedViewStore.getState().activeDetailedViewId).toBe("view-1");
+
+ chatStore.getState().selectThread("thread-2");
+ await flushPromises();
+
+ expect(detailedViewStore.getState().activeDetailedViewId).toBeNull();
+
+ unsubscribe();
+ });
+
+ it("clears active view when switchToNewThread is called", async () => {
+ const { chatStore, detailedViewStore, unsubscribe } = setupStores();
+
+ chatStore.setState({ selectedThreadId: "thread-1" });
+ detailedViewStore.getState().setActiveDetailedView("view-1");
+
+ chatStore.getState().switchToNewThread();
+ await flushPromises();
+
+ expect(detailedViewStore.getState().activeDetailedViewId).toBeNull();
+
+ unsubscribe();
+ });
+
+ it("clears active view when active thread is deleted", async () => {
+ const deleteThread = vi.fn().mockResolvedValue(undefined);
+ const chatStore = makeStore({ deleteThread });
+ const detailedViewStore = createDetailedViewStore();
+
+ const unsubscribe = chatStore.subscribe(
+ (state) => state.selectedThreadId,
+ () => detailedViewStore.getState().reset(),
+ );
+
+ chatStore.setState({
+ selectedThreadId: "thread-1",
+ threads: [
+ {
+ id: "thread-1",
+ title: "Test",
+ createdAt: new Date().toISOString(),
+ },
+ ],
+ });
+
+ detailedViewStore.getState().setActiveDetailedView("view-1");
+
+ chatStore.getState().deleteThread("thread-1");
+ await flushPromises();
+
+ expect(detailedViewStore.getState().activeDetailedViewId).toBeNull();
+
+ unsubscribe();
+ });
+
+ it("does not clear active view when re-selecting the same thread", async () => {
+ const { chatStore, detailedViewStore, unsubscribe } = setupStores();
+
+ chatStore.setState({ selectedThreadId: "thread-1" });
+ await flushPromises();
+
+ detailedViewStore.getState().setActiveDetailedView("view-1");
+ expect(detailedViewStore.getState().activeDetailedViewId).toBe("view-1");
+
+ chatStore.getState().selectThread("thread-1");
+ await flushPromises();
+
+ expect(detailedViewStore.getState().activeDetailedViewId).toBe("view-1");
+
+ unsubscribe();
+ });
+
+ it("handles rapid thread switches cleanly", async () => {
+ const getMessages = vi.fn().mockResolvedValue([]);
+ const chatStore = makeStore({ getMessages });
+ const detailedViewStore = createDetailedViewStore();
+
+ const unsubscribe = chatStore.subscribe(
+ (state) => state.selectedThreadId,
+ () => detailedViewStore.getState().reset(),
+ );
+
+ detailedViewStore.getState().setActiveDetailedView("view-1");
+
+ chatStore.getState().selectThread("thread-1");
+ chatStore.getState().selectThread("thread-2");
+ chatStore.getState().selectThread("thread-3");
+ await flushPromises();
+
+ expect(detailedViewStore.getState().activeDetailedViewId).toBeNull();
+ expect(chatStore.getState().selectedThreadId).toBe("thread-3");
+
+ unsubscribe();
+ });
+});
diff --git a/packages/react-headless/src/store/__tests__/threadContextSwitch.test.ts b/packages/react-headless/src/store/__tests__/threadContextSwitch.test.ts
new file mode 100644
index 000000000..c4cf1e674
--- /dev/null
+++ b/packages/react-headless/src/store/__tests__/threadContextSwitch.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it, vi } from "vitest";
+import { createThreadContextStore } from "../createThreadContextStore";
+import { makeStore } from "./__helpers/makeStore";
+
+const flushPromises = () => new Promise((r) => setTimeout(r, 0));
+
+describe("thread-context thread-switch cleanup", () => {
+ const setupStores = () => {
+ const chatStore = makeStore();
+ const threadContextStore = createThreadContextStore();
+
+ const unsubscribe = chatStore.subscribe(
+ (state) => state.selectedThreadId,
+ () => threadContextStore.getState().reset(),
+ );
+
+ return { chatStore, threadContextStore, unsubscribe };
+ };
+
+ const populate = (store: ReturnType) => {
+ store
+ .getState()
+ .registerArtifact({ id: "app-1", version: 1, heading: "App", type: "th_dashboard" });
+ store
+ .getState()
+ .registerArtifact({ id: "art-1", version: 1, heading: "Artifact", type: "th_presentation" });
+ };
+
+ const expectEmpty = (store: ReturnType) => {
+ expect(store.getState().artifacts).toEqual({});
+ };
+
+ it("clears thread context when selectThread is called", async () => {
+ const { chatStore, threadContextStore, unsubscribe } = setupStores();
+
+ populate(threadContextStore);
+
+ chatStore.getState().selectThread("thread-2");
+ await flushPromises();
+
+ expectEmpty(threadContextStore);
+
+ unsubscribe();
+ });
+
+ it("clears thread context when switchToNewThread is called", async () => {
+ const { chatStore, threadContextStore, unsubscribe } = setupStores();
+
+ chatStore.setState({ selectedThreadId: "thread-1" });
+ populate(threadContextStore);
+
+ chatStore.getState().switchToNewThread();
+ await flushPromises();
+
+ expectEmpty(threadContextStore);
+
+ unsubscribe();
+ });
+
+ it("clears thread context when active thread is deleted", async () => {
+ const deleteThread = vi.fn().mockResolvedValue(undefined);
+ const chatStore = makeStore({ deleteThread });
+ const threadContextStore = createThreadContextStore();
+
+ const unsubscribe = chatStore.subscribe(
+ (state) => state.selectedThreadId,
+ () => threadContextStore.getState().reset(),
+ );
+
+ chatStore.setState({
+ selectedThreadId: "thread-1",
+ threads: [
+ {
+ id: "thread-1",
+ title: "Test",
+ createdAt: new Date().toISOString(),
+ },
+ ],
+ });
+
+ populate(threadContextStore);
+
+ chatStore.getState().deleteThread("thread-1");
+ await flushPromises();
+
+ expectEmpty(threadContextStore);
+
+ unsubscribe();
+ });
+
+ it("does not clear thread context when re-selecting the same thread", async () => {
+ const { chatStore, threadContextStore, unsubscribe } = setupStores();
+
+ chatStore.setState({ selectedThreadId: "thread-1" });
+ await flushPromises();
+
+ populate(threadContextStore);
+
+ chatStore.getState().selectThread("thread-1");
+ await flushPromises();
+
+ expect(threadContextStore.getState().artifacts["app-1"]?.length).toBe(1);
+ expect(threadContextStore.getState().artifacts["art-1"]?.length).toBe(1);
+
+ unsubscribe();
+ });
+
+ it("handles rapid thread switches cleanly", async () => {
+ const getMessages = vi.fn().mockResolvedValue([]);
+ const chatStore = makeStore({ getMessages });
+ const threadContextStore = createThreadContextStore();
+
+ const unsubscribe = chatStore.subscribe(
+ (state) => state.selectedThreadId,
+ () => threadContextStore.getState().reset(),
+ );
+
+ populate(threadContextStore);
+
+ chatStore.getState().selectThread("thread-1");
+ chatStore.getState().selectThread("thread-2");
+ chatStore.getState().selectThread("thread-3");
+ await flushPromises();
+
+ expectEmpty(threadContextStore);
+ expect(chatStore.getState().selectedThreadId).toBe("thread-3");
+
+ unsubscribe();
+ });
+});
diff --git a/packages/react-headless/src/store/__tests__/toolActivity.test.ts b/packages/react-headless/src/store/__tests__/toolActivity.test.ts
new file mode 100644
index 000000000..5bb4d81b3
--- /dev/null
+++ b/packages/react-headless/src/store/__tests__/toolActivity.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it } from "vitest";
+import type { AssistantMessage, ToolCall, ToolMessage } from "../../types";
+import { pairToolActivity, partialJSONParse } from "../toolActivity";
+
+const tc = (id: string, name: string, args: string): ToolCall => ({
+ id,
+ type: "function",
+ function: { name, arguments: args },
+});
+
+const assistant = (toolCalls: ToolCall[]): AssistantMessage => ({
+ id: "a1",
+ role: "assistant",
+ content: "",
+ toolCalls,
+});
+
+const toolMsg = (toolCallId: string, content: string, error?: string): ToolMessage => ({
+ id: `t_${toolCallId}`,
+ role: "tool",
+ toolCallId,
+ content,
+ ...(error ? { error } : {}),
+});
+
+describe("partialJSONParse", () => {
+ it("returns {} for empty input", () => {
+ expect(partialJSONParse("")).toEqual({});
+ });
+
+ it("parses complete JSON", () => {
+ expect(partialJSONParse('{"a":1,"b":"x"}')).toEqual({ a: 1, b: "x" });
+ });
+
+ it("balances a truncated object (open brace)", () => {
+ expect(partialJSONParse('{"a":1,"b":2')).toEqual({ a: 1, b: 2 });
+ });
+
+ it("closes a mid-stream string", () => {
+ expect(partialJSONParse('{"a":"hel')).toEqual({ a: "hel" });
+ });
+
+ it("drops a dangling key awaiting a value (trailing colon)", () => {
+ expect(partialJSONParse('{"a":1,"b":')).toEqual({ a: 1 });
+ });
+
+ it("keeps complete array elements when truncated", () => {
+ expect(partialJSONParse('["a","b"')).toEqual(["a", "b"]);
+ });
+
+ it("never throws on garbage", () => {
+ expect(partialJSONParse("not json")).toEqual({});
+ });
+});
+
+describe("pairToolActivity", () => {
+ it("reports `streaming` while args arrive with no result and not executing", () => {
+ const [a] = pairToolActivity(assistant([tc("1", "search", '{"q":"par')]), []);
+ expect(a?.status).toBe("streaming");
+ expect(a?.input).toEqual({ q: "par" });
+ expect(a?.result).toBeUndefined();
+ expect(a?.isError).toBe(false);
+ });
+
+ it("reports `executing` when args have closed (in the executing set) with no result", () => {
+ const [a] = pairToolActivity(
+ assistant([tc("1", "search", '{"q":"paris"}')]),
+ [],
+ new Set(["1"]),
+ );
+ expect(a?.status).toBe("executing");
+ expect(a?.input).toEqual({ q: "paris" });
+ });
+
+ it("reports `complete` when a result message lands without error", () => {
+ const [a] = pairToolActivity(assistant([tc("1", "search", '{"q":"paris"}')]), [
+ toolMsg("1", '{"ok":true}'),
+ ]);
+ expect(a?.status).toBe("complete");
+ expect(a?.result).toBe('{"ok":true}');
+ expect(a?.isError).toBe(false);
+ });
+
+ it("reports `error` when the result message carries an error", () => {
+ const [a] = pairToolActivity(assistant([tc("1", "search", "{}")]), [
+ toolMsg("1", "boom", "tool exploded"),
+ ]);
+ expect(a?.status).toBe("error");
+ expect(a?.isError).toBe(true);
+ if (a?.status === "error") expect(a.errorText).toBe("tool exploded");
+ });
+
+ it("pairs by toolCallId regardless of message position (no positional break)", () => {
+ // Tool result for call "2" appears before the result for call "1".
+ const activities = pairToolActivity(assistant([tc("1", "a", "{}"), tc("2", "b", "{}")]), [
+ toolMsg("2", "r2"),
+ toolMsg("1", "r1"),
+ ]);
+ expect(activities).toHaveLength(2);
+ expect(activities[0]?.id).toBe("1");
+ expect(activities[0]?.result).toBe("r1");
+ expect(activities[1]?.id).toBe("2");
+ expect(activities[1]?.result).toBe("r2");
+ });
+
+ it("reads the deprecated `_title` arg key into statusMessage", () => {
+ const [a] = pairToolActivity(assistant([tc("1", "x", '{"_title":"Searching"}')]), []);
+ expect(a?.statusMessage).toBe("Searching");
+ });
+
+ it("renders an orphan tool result (a trailing tool message with no matching call)", () => {
+ const a = assistant([]); // no tool calls
+ const tm = toolMsg("orphan-1", "the result");
+ const activities = pairToolActivity(a, [a, tm]);
+ expect(activities).toHaveLength(1);
+ expect(activities[0]?.toolCall.id).toBe("orphan-1");
+ expect(activities[0]?.status).toBe("complete");
+ expect(activities[0]?.result).toBe("the result");
+ });
+
+ it("gives two orphan results sharing a toolCallId distinct activity ids (no key collision)", () => {
+ const a = assistant([]);
+ const tm1: ToolMessage = { id: "tm-a", role: "tool", toolCallId: "dup", content: "r1" };
+ const tm2: ToolMessage = { id: "tm-b", role: "tool", toolCallId: "dup", content: "r2" };
+ const activities = pairToolActivity(a, [a, tm1, tm2]);
+ expect(activities).toHaveLength(2);
+ expect(new Set(activities.map((x) => x.id)).size).toBe(2);
+ });
+
+ it("does not double-render a result owned by another assistant message's call", () => {
+ const a1 = assistant([tc("1", "x", "{}")]); // owns call "1"
+ const a2: AssistantMessage = { id: "a2", role: "assistant", content: "", toolCalls: [] };
+ const tm = toolMsg("1", "r1");
+ // Order a1, a2, tm: tm positionally trails a2 but its call belongs to a1.
+ expect(pairToolActivity(a2, [a1, a2, tm])).toHaveLength(0); // not an orphan under a2
+ const owned = pairToolActivity(a1, [a1, a2, tm]);
+ expect(owned).toHaveLength(1); // paired by id under a1
+ expect(owned[0]?.result).toBe("r1");
+ });
+});
diff --git a/packages/react-headless/src/store/artifactCategories.ts b/packages/react-headless/src/store/artifactCategories.ts
new file mode 100644
index 000000000..2216fb221
--- /dev/null
+++ b/packages/react-headless/src/store/artifactCategories.ts
@@ -0,0 +1,72 @@
+import type { ReactNode } from "react";
+
+import type { ArtifactCategory } from "../adapters/types";
+import type { ArtifactRendererConfig } from "./artifactRendererTypes";
+
+/**
+ * One named artifact category and the renderers that belong to it. Passed to
+ * {@link defineArtifactCategories}.
+ *
+ * @category Types
+ */
+export interface ArtifactCategoryGroup {
+ /** Display label and key for the category, e.g. `"Reports"`. */
+ name: string;
+ /**
+ * Renderers in this category. Each renderer's `type` populates the category's
+ * filter, and the renderers themselves are collected into `artifactRenderers`.
+ */
+ renderers: ArtifactRendererConfig[];
+ /** Sidebar nav icon for the category. Omit to fall back to the default. */
+ icon?: ReactNode;
+}
+
+/**
+ * Wraps the manual `artifactRenderers` + `artifactCategories` wiring: declare
+ * each category once with its renderers, and get both props back, ready to
+ * spread onto ``.
+ *
+ * - `artifactRenderers` is every group's renderers, in order, de-duplicated by
+ * identity (a renderer listed in two groups registers once).
+ * - `artifactCategories` is one {@link ArtifactCategory} per group, its
+ * `filter.type` collected from the group's renderer `type`s (de-duplicated).
+ *
+ * @category Functions
+ *
+ * @example
+ * ```tsx
+ * const artifacts = defineArtifactCategories([
+ * { name: "Reports", renderers: [reportRenderer], icon: },
+ * { name: "Dashboards", renderers: [dashboardRenderer], icon: },
+ * ]);
+ *
+ * ;
+ * ```
+ */
+export function defineArtifactCategories(groups: ArtifactCategoryGroup[]): {
+ artifactRenderers: ArtifactRendererConfig[];
+ artifactCategories: ArtifactCategory[];
+} {
+ const artifactRenderers: ArtifactRendererConfig[] = [];
+ const seenRenderers = new Set>();
+
+ const artifactCategories = groups.map((group): ArtifactCategory => {
+ const type: string[] = [];
+ const seenTypes = new Set();
+
+ for (const renderer of group.renderers) {
+ if (!seenRenderers.has(renderer)) {
+ seenRenderers.add(renderer);
+ artifactRenderers.push(renderer);
+ }
+ if (!seenTypes.has(renderer.type)) {
+ seenTypes.add(renderer.type);
+ type.push(renderer.type);
+ }
+ }
+
+ return { name: group.name, filter: { type }, icon: group.icon };
+ });
+
+ return { artifactRenderers, artifactCategories };
+}
diff --git a/packages/react-headless/src/store/artifactRendererTypes.ts b/packages/react-headless/src/store/artifactRendererTypes.ts
new file mode 100644
index 000000000..117e011de
--- /dev/null
+++ b/packages/react-headless/src/store/artifactRendererTypes.ts
@@ -0,0 +1,147 @@
+import type { ReactNode } from "react";
+
+/**
+ * Controls passed to a renderer's `preview` and `actual` render functions.
+ *
+ * @category Types
+ */
+export interface ArtifactRendererControls {
+ /** Whether this renderer's detailed view is the currently active one. */
+ isActive: boolean;
+ /**
+ * `true` while the tool call is still streaming — i.e. its arguments are
+ * arriving incrementally and no tool result has been paired in yet. Becomes
+ * `false` once the tool result message lands and the renderer is invoked
+ * with the full `response`. Always `false` for storage-opened artifacts.
+ *
+ * The same component instance is reused across the streaming → completed
+ * transition, so renderers can rely on this flag to swap UI states (e.g.
+ * show a skeleton or "streaming…" badge during partial args, then the final
+ * view) without remounting.
+ */
+ isStreaming: boolean;
+ /** Activates this renderer's detailed view. */
+ open: () => void;
+ /** Closes this renderer's detailed view if currently active. */
+ close: () => void;
+ /** Toggles this renderer's detailed view. */
+ toggle: () => void;
+}
+
+/**
+ * Result of a renderer's `parser`.
+ *
+ * - Returning `null` from `parser` skips rendering this tool call entirely.
+ * - `meta: null` renders preview/actual but skips ThreadContext registration —
+ * a common pattern while `ctx.isStreaming` is `true` so the entry only
+ * appears in the registry once the tool result has arrived.
+ *
+ * @category Types
+ */
+export interface ParsedArtifact {
+ props: Props;
+ // `type`, when a parser provides it, is the artifact's REAL kind. It lets the
+ // ThreadContext registration label/resolve the artifact by its own kind even
+ // when a single tool-owning renderer matched the tool name (e.g. one renderer
+ // owns the generate/edit tools but emits both presentations and reports).
+ // Optional: parsers that don't set it fall back to the matched renderer's type.
+ meta: { id: string; version: number; heading: string; type?: string } | null;
+}
+
+/**
+ * Configuration for a single artifact renderer, returned by {@link defineArtifactRenderer}.
+ *
+ * Renderers are matched against tool calls by `toolName` (one or many literal
+ * strings) and against stored artifacts by `type`. When a match fires, `parser`
+ * converts the raw envelope into typed `Props` + an optional ThreadContext
+ * `meta` entry, and `preview` / `actual` render the inline chat preview and
+ * the full view respectively.
+ *
+ * @category Types
+ */
+export interface ArtifactRendererConfig {
+ /**
+ * Artifact type this renderer handles, e.g. `"th_presentation"`.
+ * Links the renderer to {@link ArtifactCategory} filters and to stored
+ * artifacts (`ArtifactSummary.type`) for thread-independent rendering.
+ */
+ type: string;
+ /**
+ * Tool name(s) to match. Literal strings only; first registration wins on
+ * duplicates. An array registers the same renderer for several tools.
+ */
+ toolName: string | string[];
+ /**
+ * Converts the raw envelope into `{ props, meta }`.
+ *
+ * Tool-call path: receives `{ args, response }` exactly as the backend
+ * emitted them — the SDK does not pre-parse JSON. Called on every update,
+ * including during streaming, so implementations must tolerate:
+ * - `args` as a *partial* JSON string (the LLM is still emitting it), and
+ * - `response` as `null` (the tool result hasn't arrived yet — see
+ * {@link ArtifactRendererControls.isStreaming}).
+ *
+ * Storage path (artifact browser): receives `{ args: undefined, response: artifact.content }` —
+ * stored `content` must therefore have the same shape as the tool-call response.
+ *
+ * Return `null` to skip rendering. Return `meta: null` to render without
+ * registering in the ThreadContext (entry hidden from workspace lists).
+ * `meta.id` should be stable across re-runs of the same logical entry —
+ * when `(id, version)` changes, the registry entry is re-registered.
+ */
+ parser: (
+ raw: { args: unknown; response: unknown },
+ ctx: { isStreaming: boolean },
+ ) => ParsedArtifact