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/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/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/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/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/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/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-cloud/.gitignore b/examples/openui-cloud/.gitignore
new file mode 100644
index 000000000..4e5c4a9a3
--- /dev/null
+++ b/examples/openui-cloud/.gitignore
@@ -0,0 +1,49 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.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..3c52af208
--- /dev/null
+++ b/examples/openui-cloud/src/app/api/chat/route.ts
@@ -0,0 +1,91 @@
+import { envOr, requiredEnv } from "@/lib/env";
+import { artifactTool, createResponsesInstructions } from "@openuidev/thesys-server";
+import OpenAI from "openai";
+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[];
+ };
+
+ 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 },
+ );
+ }
+
+ const client = new OpenAI({
+ // responses.create() POSTs to `${baseURL}/responses` → /v1/embed/responses.
+ baseURL: `${envOr("OPENUI_CLOUD_BASE_URL", "http://localhost:3102")}/v1/embed`,
+ apiKey: requiredEnv("THESYS_API_KEY"), // sent as Authorization: Bearer …
+ });
+
+ let stream: AsyncIterable>;
+ try {
+ stream = (await client.responses.create(
+ {
+ model: envOr("OPENUI_MODEL", "anthropic/claude-sonnet-4.6"),
+ conversation: threadId, // store:true persists to the conversation
+ input,
+ stream: true,
+ store: true,
+ // `artifacts` makes each entry carry library_version:'0.1.0' → openui-lang.
+ // Bare artifactTool() would fall back to the legacy XML model.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ tools: [artifactTool({ artifacts: ["slides", "report"] }) as any],
+ instructions: createResponsesInstructions(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any,
+ { signal: req.signal }, // propagate browser aborts (stop button / tab close)
+ )) as unknown as AsyncIterable>;
+ } catch (err) {
+ // The SDK surfaces upstream HTTP errors (e.g. 403) as APIError.
+ const e = err as { status?: number; error?: unknown; message?: string };
+ return Response.json(
+ { error: e.error ?? { message: e.message ?? "upstream error" } },
+ { status: e.status ?? 502 },
+ );
+ }
+
+ // Re-emit each SDK event as SSE for the browser adapter.
+ const encoder = new TextEncoder();
+ const body = new ReadableStream({
+ async start(controller) {
+ try {
+ for await (const event of stream) {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify({ type: "error", message })}\n\n`),
+ );
+ } finally {
+ controller.close();
+ }
+ },
+ });
+
+ return new Response(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..2e9dad4e0
--- /dev/null
+++ b/examples/openui-cloud/src/app/globals.css
@@ -0,0 +1,23 @@
+@import "tailwindcss";
+
+/*
+ * Integration shim (openui abhishek/openui-chat): the DetailedViewPanel that
+ * hosts an opened artifact is `display:block; flex:0 1 auto`, so inside its
+ * flex-column parent it collapses to content height. genui-sdk's SlideShow /
+ * ReportView use `h-full` and expect the parent to define a height — so the
+ * full-bleed deck shrinks to ~45px (just the controls bar) and the slide
+ * canvas renders as a tiny thumbnail. Make the panel (and the standalone
+ * artifact wrapper) fill the available column so the deck gets real height.
+ * TODO(colleague): DetailedViewPanel should flex:1 / define a height contract
+ * for full-bleed artifact content.
+ */
+.openui-detailed-view-panel {
+ flex: 1 1 0% !important;
+ min-height: 0 !important;
+ display: flex !important;
+ flex-direction: column !important;
+}
+.openui-detailed-view-panel > .thesys-artifact-standalone {
+ flex: 1 1 0% !important;
+ min-height: 0 !important;
+}
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/openui-artifact-demo/.dockerignore b/examples/openui-responses-chat/.dockerignore
similarity index 100%
rename from examples/openui-artifact-demo/.dockerignore
rename to examples/openui-responses-chat/.dockerignore
diff --git a/examples/openui-artifact-demo/.gitignore b/examples/openui-responses-chat/.gitignore
similarity index 90%
rename from examples/openui-artifact-demo/.gitignore
rename to examples/openui-responses-chat/.gitignore
index 5ef6a5207..456479ec2 100644
--- a/examples/openui-artifact-demo/.gitignore
+++ b/examples/openui-responses-chat/.gitignore
@@ -36,6 +36,9 @@ yarn-error.log*
# vercel
.vercel
+# local thread index (created at runtime)
+/.data/
+
# typescript
*.tsbuildinfo
next-env.d.ts
diff --git a/examples/openui-artifact-demo/Dockerfile b/examples/openui-responses-chat/Dockerfile
similarity index 92%
rename from examples/openui-artifact-demo/Dockerfile
rename to examples/openui-responses-chat/Dockerfile
index b3a36dfe1..d6d5be06b 100644
--- a/examples/openui-artifact-demo/Dockerfile
+++ b/examples/openui-responses-chat/Dockerfile
@@ -17,6 +17,7 @@ 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/lang-core/package.json ./packages/lang-core/
COPY packages/react-lang/package.json ./packages/react-lang/
COPY examples/openui-chat/package.json ./examples/openui-chat/
@@ -26,20 +27,20 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
COPY packages/openui-cli ./packages/openui-cli
COPY packages/react-ui ./packages/react-ui
COPY packages/react-headless ./packages/react-headless
+COPY packages/lang-core ./packages/lang-core
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/lang-core 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
# --------------------------------------------------
@@ -55,7 +56,7 @@ 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 ./
@@ -70,4 +71,4 @@ 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
+CMD ["node", "examples/openui-chat/server.js"]
diff --git a/examples/openui-responses-chat/README.md b/examples/openui-responses-chat/README.md
new file mode 100644
index 000000000..717d547ba
--- /dev/null
+++ b/examples/openui-responses-chat/README.md
@@ -0,0 +1,54 @@
+This is an [OpenUI](https://openui.com) Agent Chat project bootstrapped with [`openui-cli`](https://openui.com/docs/chat/quick-start).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `src/app/api/route.ts` and improving your agent
+by adding system prompts or tools.
+
+## Learn More
+
+To learn more about OpenUI, take a look at the following resources:
+
+- [OpenUI Documentation](https://openui.com/docs) - learn about OpenUI features and API.
+- [OpenUI GitHub repository](https://github.com/thesysdev/openui) - your feedback and contributions are welcome!
+
+## Docker Usage
+
+You can build the image either from the example directory or from the repository root.
+
+### Option 1: From examples/openui-responses-chat
+
+```bash
+cd examples/openui-responses-chat
+docker build -f Dockerfile -t openui-responses-chat ../..
+docker run --rm -p 3000:3000 -e OPENAI_API_KEY=your_api_key openui-responses-chat
+```
+
+### Option 2: From repository root
+
+```bash
+docker build -f examples/openui-responses-chat/Dockerfile -t openui-responses-chat .
+docker run --rm -p 3000:3000 -e OPENAI_API_KEY=your_api_key openui-responses-chat
+```
+
+⚠️ The build context must be the repository root (either `.` or `../..`) because this example depends on pnpm workspace packages.
+
+Notes:
+
+- The Docker build uses pnpm workspace dependencies from the monorepo.
+- Runtime uses Next.js standalone output (`node examples/openui-responses-chat/server.js`).
+- A placeholder API key will start the app, but chat requests will return `401`.
diff --git a/examples/openui-responses-chat/eslint.config.mjs b/examples/openui-responses-chat/eslint.config.mjs
new file mode 100644
index 000000000..05e726d1b
--- /dev/null
+++ b/examples/openui-responses-chat/eslint.config.mjs
@@ -0,0 +1,18 @@
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+import nextTs from "eslint-config-next/typescript";
+
+const eslintConfig = defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ // Override default ignores of eslint-config-next.
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ]),
+]);
+
+export default eslintConfig;
diff --git a/examples/openui-artifact-demo/next.config.ts b/examples/openui-responses-chat/next.config.ts
similarity index 100%
rename from examples/openui-artifact-demo/next.config.ts
rename to examples/openui-responses-chat/next.config.ts
diff --git a/examples/openui-artifact-demo/package.json b/examples/openui-responses-chat/package.json
similarity index 79%
rename from examples/openui-artifact-demo/package.json
rename to examples/openui-responses-chat/package.json
index 2f5f0184c..6365a4ccf 100644
--- a/examples/openui-artifact-demo/package.json
+++ b/examples/openui-responses-chat/package.json
@@ -1,5 +1,5 @@
{
- "name": "openui-artifact-demo",
+ "name": "openui-responses-chat",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -14,22 +14,20 @@
"@openuidev/react-lang": "workspace:*",
"@openuidev/react-ui": "workspace:*",
"lucide-react": "^0.575.0",
- "next": "16.2.6",
+ "next": "16.1.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/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9",
- "eslint-config-next": "16.2.6",
+ "eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
diff --git a/examples/openui-responses-chat/postcss.config.mjs b/examples/openui-responses-chat/postcss.config.mjs
new file mode 100644
index 000000000..61e36849c
--- /dev/null
+++ b/examples/openui-responses-chat/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/examples/openui-responses-chat/src/app/api/chat/route.ts b/examples/openui-responses-chat/src/app/api/chat/route.ts
new file mode 100644
index 000000000..b47356dc0
--- /dev/null
+++ b/examples/openui-responses-chat/src/app/api/chat/route.ts
@@ -0,0 +1,134 @@
+import { readFileSync } from "fs";
+import { NextRequest } from "next/server";
+import OpenAI from "openai";
+import type {
+ ResponseFunctionToolCall,
+ ResponseInputItem,
+ ResponseStreamEvent,
+} from "openai/resources/responses/responses";
+import { join } from "path";
+import { executeTool, tools } from "@/lib/tools";
+
+const systemPrompt = readFileSync(
+ join(process.cwd(), "src/generated/system-prompt.txt"),
+ "utf-8",
+);
+
+const MODEL = "gpt-5.4";
+const MAX_TOOL_TURNS = 5;
+
+export async function POST(req: NextRequest) {
+ const { input, threadId } = (await req.json()) as {
+ input: ResponseInputItem[];
+ threadId?: string;
+ };
+
+ const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
+
+ const encoder = new TextEncoder();
+ let controllerClosed = false;
+
+ const readable = new ReadableStream({
+ async start(controller) {
+ const enqueue = (chunk: string) => {
+ if (controllerClosed) return;
+ try {
+ controller.enqueue(encoder.encode(chunk));
+ } catch {
+ /* already closed */
+ }
+ };
+ const close = () => {
+ if (controllerClosed) return;
+ controllerClosed = true;
+ try {
+ controller.close();
+ } catch {
+ /* already closed */
+ }
+ };
+
+ try {
+ let nextInput: ResponseInputItem[] = input;
+
+ for (let turn = 0; turn < MAX_TOOL_TURNS; turn++) {
+ const stream = await client.responses.create({
+ model: MODEL,
+ instructions: systemPrompt,
+ input: nextInput,
+ conversation: threadId,
+ tools,
+ stream: true,
+ });
+
+ let lastResponse: ResponseStreamEvent extends infer E
+ ? E extends { type: "response.completed"; response: infer R }
+ ? R
+ : never
+ : never = null as never;
+
+ for await (const event of stream) {
+ enqueue(`data: ${JSON.stringify(event)}\n\n`);
+ if (event.type === "response.completed") {
+ lastResponse = event.response as typeof lastResponse;
+ }
+ }
+
+ const fnCalls: ResponseFunctionToolCall[] = (lastResponse?.output ?? []).filter(
+ (o): o is ResponseFunctionToolCall => o.type === "function_call",
+ );
+
+ if (fnCalls.length === 0) break;
+
+ const outputs: ResponseInputItem[] = [];
+ for (const fc of fnCalls) {
+ const result = await executeTool(fc.name, fc.arguments);
+
+ // OpenAI doesn't echo function_call_output items in the response
+ // stream — it absorbs them into the conversation silently. Inject
+ // a synthetic OpenAI-shape event so the SDK adapter can surface
+ // the tool result to the live store (mirrors a real
+ // `response.output_item.added` for a function_call_output item).
+ enqueue(
+ `data: ${JSON.stringify({
+ type: "response.output_item.added",
+ item: {
+ id: `fco_${fc.call_id}`,
+ type: "function_call_output",
+ call_id: fc.call_id,
+ output: result,
+ status: "completed",
+ },
+ output_index: 0,
+ sequence_number: 0,
+ })}\n\n`,
+ );
+
+ outputs.push({
+ type: "function_call_output",
+ call_id: fc.call_id,
+ output: result,
+ });
+ }
+ nextInput = outputs;
+ }
+
+ enqueue("data: [DONE]\n\n");
+ close();
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "Stream error";
+ console.error("Responses route error:", msg);
+ enqueue(`data: ${JSON.stringify({ type: "error", message: 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-responses-chat/src/app/api/threads/create/route.ts b/examples/openui-responses-chat/src/app/api/threads/create/route.ts
new file mode 100644
index 000000000..4e68e32bf
--- /dev/null
+++ b/examples/openui-responses-chat/src/app/api/threads/create/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest } from "next/server";
+import OpenAI from "openai";
+import type { ResponseInputItem } from "openai/resources/responses/responses";
+import { addThread } from "@/lib/thread-index";
+
+function extractFirstUserText(items: ResponseInputItem[]): string {
+ for (const item of items) {
+ const i = item as { type?: string; role?: string; content?: unknown };
+ if (i.type === "message" && i.role === "user") {
+ if (typeof i.content === "string") return i.content;
+ if (Array.isArray(i.content)) {
+ for (const part of i.content as Array<{ type: string; text?: string }>) {
+ if (part.type === "input_text" && part.text) return part.text;
+ }
+ }
+ }
+ }
+ return "New conversation";
+}
+
+export async function POST(req: NextRequest) {
+ const { messages } = (await req.json()) as { messages: ResponseInputItem[] };
+
+ const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
+
+ const conversation = await client.conversations.create({});
+
+ const title = extractFirstUserText(messages).slice(0, 60);
+ const thread = {
+ id: conversation.id,
+ title,
+ createdAt: new Date(conversation.created_at * 1000).toISOString(),
+ };
+
+ await addThread(thread);
+ return Response.json(thread);
+}
diff --git a/examples/openui-responses-chat/src/app/api/threads/delete/[id]/route.ts b/examples/openui-responses-chat/src/app/api/threads/delete/[id]/route.ts
new file mode 100644
index 000000000..6f0ea93f6
--- /dev/null
+++ b/examples/openui-responses-chat/src/app/api/threads/delete/[id]/route.ts
@@ -0,0 +1,19 @@
+import OpenAI from "openai";
+import { removeThread } from "@/lib/thread-index";
+
+export async function DELETE(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
+
+ try {
+ await client.conversations.delete(id);
+ } catch (e) {
+ console.warn("Failed to delete OpenAI conversation; removing from index anyway:", e);
+ }
+ await removeThread(id);
+
+ return new Response(null, { status: 204 });
+}
diff --git a/examples/openui-responses-chat/src/app/api/threads/get/[id]/route.ts b/examples/openui-responses-chat/src/app/api/threads/get/[id]/route.ts
new file mode 100644
index 000000000..fe327ec28
--- /dev/null
+++ b/examples/openui-responses-chat/src/app/api/threads/get/[id]/route.ts
@@ -0,0 +1,20 @@
+import OpenAI from "openai";
+import type { ConversationItem } from "openai/resources/conversations/items";
+
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
+
+ const items: ConversationItem[] = [];
+ for await (const item of client.conversations.items.list(id, {
+ limit: 100,
+ order: "asc",
+ })) {
+ items.push(item);
+ }
+
+ return Response.json(items);
+}
diff --git a/examples/openui-responses-chat/src/app/api/threads/get/route.ts b/examples/openui-responses-chat/src/app/api/threads/get/route.ts
new file mode 100644
index 000000000..8e58bedde
--- /dev/null
+++ b/examples/openui-responses-chat/src/app/api/threads/get/route.ts
@@ -0,0 +1,6 @@
+import { readIndex } from "@/lib/thread-index";
+
+export async function GET() {
+ const threads = await readIndex();
+ return Response.json({ threads });
+}
diff --git a/examples/openui-responses-chat/src/app/api/threads/update/[id]/route.ts b/examples/openui-responses-chat/src/app/api/threads/update/[id]/route.ts
new file mode 100644
index 000000000..507ca893b
--- /dev/null
+++ b/examples/openui-responses-chat/src/app/api/threads/update/[id]/route.ts
@@ -0,0 +1,20 @@
+import { NextRequest } from "next/server";
+import OpenAI from "openai";
+import { updateIndexEntry } from "@/lib/thread-index";
+
+export async function PATCH(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const updates = (await req.json()) as { title?: string };
+
+ const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
+
+ if (typeof updates.title === "string") {
+ await client.conversations.update(id, { metadata: { title: updates.title } });
+ }
+
+ const updated = await updateIndexEntry(id, { title: updates.title });
+ return Response.json(updated ?? { id, ...updates });
+}
diff --git a/examples/openui-responses-chat/src/app/globals.css b/examples/openui-responses-chat/src/app/globals.css
new file mode 100644
index 000000000..3d552a61f
--- /dev/null
+++ b/examples/openui-responses-chat/src/app/globals.css
@@ -0,0 +1,2 @@
+@import "tailwindcss";
+
diff --git a/examples/openui-responses-chat/src/app/layout.tsx b/examples/openui-responses-chat/src/app/layout.tsx
new file mode 100644
index 000000000..7e44b0451
--- /dev/null
+++ b/examples/openui-responses-chat/src/app/layout.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from "next";
+import { ThemeProvider } from "@/hooks/use-system-theme";
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: "OpenUI Chat",
+ description: "Generative UI Chat with OpenAI SDK",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/examples/openui-responses-chat/src/app/page.tsx b/examples/openui-responses-chat/src/app/page.tsx
new file mode 100644
index 000000000..f484f98b4
--- /dev/null
+++ b/examples/openui-responses-chat/src/app/page.tsx
@@ -0,0 +1,85 @@
+"use client";
+import "@openuidev/react-ui/components.css";
+
+import { useTheme } from "@/hooks/use-system-theme";
+import { codeArtifactRenderer } from "@/lib/codeArtifactRenderer";
+import {
+ AgentInterface,
+ openAIConversationMessageFormat,
+ openAIResponsesAdapter,
+ restStorage,
+ 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();
+
+ // Thread persistence stays server-backed via the /api/threads REST contract.
+ // /api/threads/create mints the OpenAI conversation id that /api/chat passes to
+ // `client.responses.create({ conversation: threadId })`, so threadId MUST come
+ // from the backend — a client-minted UUID would be rejected. restStorage keeps
+ // loadThread deserialization aligned with the OpenAI conversation format.
+ const storage = useMemo(
+ () => restStorage({ baseUrl: "/api/threads", messageFormat: openAIConversationMessageFormat }),
+ [],
+ );
+ const llm = useMemo(
+ () => ({
+ send: async ({ threadId, messages, signal }) => {
+ // OpenAI persists via `conversation: threadId` linkage, so send
+ // only the latest message — full history lives server-side.
+ 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(),
+ }),
+ [],
+ );
+
+ return (
+
+ );
+}
diff --git a/examples/openui-artifact-demo/src/generated/system-prompt.txt b/examples/openui-responses-chat/src/generated/system-prompt.txt
similarity index 92%
rename from examples/openui-artifact-demo/src/generated/system-prompt.txt
rename to examples/openui-responses-chat/src/generated/system-prompt.txt
index f7263f410..e98309971 100644
--- a/examples/openui-artifact-demo/src/generated/system-prompt.txt
+++ b/examples/openui-responses-chat/src/generated/system-prompt.txt
@@ -28,7 +28,6 @@ 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.
@@ -201,16 +200,6 @@ emailField = FormControl("Email", Input("email", "you@example.com", "email", { r
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.)
@@ -232,8 +221,3 @@ Before finishing, walk your output and verify:
- 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-responses-chat/src/hooks/use-system-theme.tsx b/examples/openui-responses-chat/src/hooks/use-system-theme.tsx
new file mode 100644
index 000000000..7c110c21d
--- /dev/null
+++ b/examples/openui-responses-chat/src/hooks/use-system-theme.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { createContext, useContext, useLayoutEffect, useState } from "react";
+
+type ThemeMode = "light" | "dark";
+
+interface ThemeContextType {
+ mode: ThemeMode;
+}
+
+const ThemeContext = createContext(undefined);
+
+function getSystemMode(): ThemeMode {
+ if (typeof window === "undefined") return "light";
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+}
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const [mode, setMode] = useState(getSystemMode);
+
+ useLayoutEffect(() => {
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
+ const handler = (e: MediaQueryListEvent) => setMode(e.matches ? "dark" : "light");
+ mq.addEventListener("change", handler);
+ return () => mq.removeEventListener("change", handler);
+ }, []);
+
+ useLayoutEffect(() => {
+ document.body.setAttribute("data-theme", mode);
+ }, [mode]);
+
+ return {children} ;
+}
+
+export function useTheme(): ThemeMode {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) {
+ throw new Error("useTheme must be used within a ThemeProvider");
+ }
+ return ctx.mode;
+}
diff --git a/examples/openui-responses-chat/src/lib/codeArtifactRenderer.tsx b/examples/openui-responses-chat/src/lib/codeArtifactRenderer.tsx
new file mode 100644
index 000000000..88598b13c
--- /dev/null
+++ b/examples/openui-responses-chat/src/lib/codeArtifactRenderer.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { CodeBlock, defineArtifactRenderer } from "@openuidev/react-ui";
+
+type CodeArtifactProps = {
+ language: string;
+ title: string;
+ code: string;
+};
+
+// Best-effort extractor for partial JSON args during streaming.
+// Strict mode + property order (language → title → code) means we can pull
+// completed fields out even when the tail of `code` hasn't streamed in yet.
+function parseCodeArgs(raw: string): CodeArtifactProps | null {
+ if (!raw) return null;
+
+ try {
+ const parsed = JSON.parse(raw) as Partial;
+ return {
+ language: parsed.language ?? "",
+ title: parsed.title ?? "",
+ code: parsed.code ?? "",
+ };
+ } catch {
+ // Fall through to partial extraction
+ }
+
+ const language = raw.match(/"language"\s*:\s*"([^"]*)/)?.[1] ?? "";
+ const title = raw.match(/"title"\s*:\s*"([^"]*)/)?.[1] ?? "";
+ // Capture from `"code":"` up to the unescaped closing `"` (or end of buffer).
+ const codeMatch = raw.match(/"code"\s*:\s*"((?:[^"\\]|\\.)*)/);
+ const code = codeMatch ? unescapeJSONString(codeMatch[1]) : "";
+
+ if (!language && !title && !code) return null;
+ return { language, title, code };
+}
+
+function unescapeJSONString(s: string): string {
+ return s
+ .replace(/\\n/g, "\n")
+ .replace(/\\t/g, "\t")
+ .replace(/\\r/g, "\r")
+ .replace(/\\"/g, '"')
+ .replace(/\\\\/g, "\\");
+}
+
+export const codeArtifactRenderer = defineArtifactRenderer({
+ type: "code_artifact",
+ toolName: "create_code_artifact",
+ parser: ({ args }) => {
+ if (typeof args !== "string") return null;
+ const props = parseCodeArgs(args);
+ if (!props) return null;
+ return {
+ props,
+ meta: props.title ? { id: `code:${props.title}`, version: 1, heading: props.title } : null,
+ };
+ },
+ preview: (props, { isStreaming, isActive, toggle }) => (
+
+
+ {props.title || "Code artifact"}
+
+ {props.language}
+ {isStreaming ? " · streaming…" : ""}
+ {isActive ? " · open" : ""}
+
+
+
+ {props.code.split("\n").slice(0, 5).join("\n")}
+
+
+ ),
+ actual: (props) => (
+
+
+
+ ),
+});
diff --git a/examples/openui-responses-chat/src/lib/thread-index.ts b/examples/openui-responses-chat/src/lib/thread-index.ts
new file mode 100644
index 000000000..c89d3f721
--- /dev/null
+++ b/examples/openui-responses-chat/src/lib/thread-index.ts
@@ -0,0 +1,59 @@
+import { promises as fs } from "fs";
+import { join } from "path";
+
+const DATA_DIR = join(process.cwd(), ".data");
+const INDEX_FILE = join(DATA_DIR, "threads.json");
+
+export type IndexedThread = {
+ id: string;
+ title: string;
+ createdAt: string;
+};
+
+async function ensureFile(): Promise {
+ await fs.mkdir(DATA_DIR, { recursive: true });
+ try {
+ await fs.access(INDEX_FILE);
+ } catch {
+ await fs.writeFile(INDEX_FILE, "[]");
+ }
+}
+
+export async function readIndex(): Promise {
+ await ensureFile();
+ const raw = await fs.readFile(INDEX_FILE, "utf-8");
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+async function writeIndex(threads: IndexedThread[]): Promise {
+ await ensureFile();
+ await fs.writeFile(INDEX_FILE, JSON.stringify(threads, null, 2));
+}
+
+export async function addThread(thread: IndexedThread): Promise {
+ const all = await readIndex();
+ all.unshift(thread);
+ await writeIndex(all);
+}
+
+export async function removeThread(id: string): Promise {
+ const all = await readIndex();
+ await writeIndex(all.filter((t) => t.id !== id));
+}
+
+export async function updateIndexEntry(
+ id: string,
+ patch: Partial,
+): Promise {
+ const all = await readIndex();
+ const idx = all.findIndex((t) => t.id === id);
+ if (idx === -1) return null;
+ all[idx] = { ...all[idx], ...patch };
+ await writeIndex(all);
+ return all[idx];
+}
diff --git a/examples/openui-responses-chat/src/lib/tools.ts b/examples/openui-responses-chat/src/lib/tools.ts
new file mode 100644
index 000000000..7983ee124
--- /dev/null
+++ b/examples/openui-responses-chat/src/lib/tools.ts
@@ -0,0 +1,194 @@
+import type { FunctionTool } from "openai/resources/responses/responses";
+
+export const tools: FunctionTool[] = [
+ {
+ type: "function",
+ name: "get_weather",
+ description: "Get current weather for a location.",
+ parameters: {
+ type: "object",
+ properties: { location: { type: "string", description: "City name" } },
+ required: ["location"],
+ additionalProperties: false,
+ },
+ strict: true,
+ },
+ {
+ type: "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"],
+ additionalProperties: false,
+ },
+ strict: true,
+ },
+ {
+ type: "function",
+ name: "calculate",
+ description: "Evaluate a math expression.",
+ parameters: {
+ type: "object",
+ properties: {
+ expression: { type: "string", description: "Math expression to evaluate" },
+ },
+ required: ["expression"],
+ additionalProperties: false,
+ },
+ strict: true,
+ },
+ {
+ type: "function",
+ name: "search_web",
+ description: "Search the web for information.",
+ parameters: {
+ type: "object",
+ properties: { query: { type: "string", description: "Search query" } },
+ required: ["query"],
+ additionalProperties: false,
+ },
+ strict: true,
+ },
+ {
+ type: "function",
+ name: "create_code_artifact",
+ description:
+ "Render a code snippet artifact for the user. Use this whenever the user asks for code.",
+ parameters: {
+ type: "object",
+ // Property order is preserved by strict mode; keep `code` last so the
+ // frontend can extract earlier fields (language, title) from partial
+ // streaming JSON before `code` is complete.
+ properties: {
+ language: {
+ type: "string",
+ description: "Language identifier (e.g. 'python', 'typescript', 'rust').",
+ },
+ title: {
+ type: "string",
+ description: "Short title for the artifact.",
+ },
+ code: {
+ type: "string",
+ description: "The code content.",
+ },
+ },
+ required: ["language", "title", "code"],
+ additionalProperties: false,
+ },
+ strict: true,
+ },
+];
+
+type ToolImpl = (args: Record) => Promise;
+
+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 knownPrices: Record = {
+ AAPL: 189.84,
+ GOOGL: 141.8,
+ TSLA: 248.42,
+ MSFT: 378.91,
+ AMZN: 178.25,
+ NVDA: 875.28,
+ META: 485.58,
+};
+
+export const toolImpls: Record = {
+ get_weather: async ({ location }) => {
+ const loc = String(location ?? "");
+ const temp = knownTemps[loc.toLowerCase()] ?? Math.floor(Math.random() * 30 + 5);
+ const condition = conditions[Math.floor(Math.random() * conditions.length)];
+ return {
+ location: loc,
+ 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" },
+ ],
+ };
+ },
+
+ get_stock_price: async ({ symbol }) => {
+ const s = String(symbol ?? "").toUpperCase();
+ const price = knownPrices[s] ?? Math.floor(Math.random() * 500 + 20);
+ const change = parseFloat((Math.random() * 8 - 4).toFixed(2));
+ return {
+ 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)),
+ };
+ },
+
+ calculate: async ({ expression }) => {
+ const expr = String(expression ?? "");
+ try {
+ const sanitized = expr.replace(/[^0-9+\-*/().%\s,Math.sqrtpowabsceilfloorround]/g, "");
+ const result = new Function(`return (${sanitized})`)();
+ return { expression: expr, result: Number(result) };
+ } catch {
+ return { expression: expr, error: "Invalid expression" };
+ }
+ },
+
+ search_web: async ({ query }) => {
+ const q = String(query ?? "");
+ return {
+ query: q,
+ results: [
+ {
+ title: `Top result for "${q}"`,
+ snippet: `Comprehensive overview of ${q} with the latest information.`,
+ },
+ {
+ title: `${q} - Latest News`,
+ snippet: `Recent developments and updates related to ${q}.`,
+ },
+ {
+ title: `Understanding ${q}`,
+ snippet: `An in-depth guide explaining everything about ${q}.`,
+ },
+ ],
+ };
+ },
+
+ // No-op: the artifact lives entirely in the tool-call args. The frontend
+ // renders from streaming args; this echo is just a marker for the LLM that
+ // the artifact was delivered.
+ create_code_artifact: async (args) => ({ ok: true, ...args }),
+};
+
+export async function executeTool(name: string, args: string): Promise {
+ const impl = toolImpls[name];
+ if (!impl) return JSON.stringify({ error: `Tool '${name}' not implemented` });
+ let parsed: Record = {};
+ try {
+ parsed = JSON.parse(args);
+ } catch {
+ return JSON.stringify({ error: "Invalid arguments JSON" });
+ }
+ const result = await impl(parsed);
+ return typeof result === "string" ? result : JSON.stringify(result);
+}
diff --git a/examples/openui-responses-chat/src/library.ts b/examples/openui-responses-chat/src/library.ts
new file mode 100644
index 000000000..c7ceecfc1
--- /dev/null
+++ b/examples/openui-responses-chat/src/library.ts
@@ -0,0 +1 @@
+export { openuiChatLibrary as library, openuiChatPromptOptions as promptOptions } from "@openuidev/react-ui/genui-lib";
diff --git a/examples/openui-responses-chat/tsconfig.json b/examples/openui-responses-chat/tsconfig.json
new file mode 100644
index 000000000..cf9c65d3e
--- /dev/null
+++ b/examples/openui-responses-chat/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}
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/src/parser/parser.ts b/packages/lang-core/src/parser/parser.ts
index 9be78733c..2d7029f62 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,15 @@ 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 +532,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 +550,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 +596,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..305d91487 100644
--- a/packages/openui-cli/package.json
+++ b/packages/openui-cli/package.json
@@ -53,6 +53,9 @@
"@inquirer/core": "^11.1.5",
"@inquirer/prompts": "^8.3.0",
"commander": "^14.0.3",
- "esbuild": "^0.25.10"
+ "esbuild": "^0.25.10",
+ "open": "^10.1.0",
+ "openid-client": "^6.1.7",
+ "posthog-node": "^5.35.6"
}
}
diff --git a/packages/openui-cli/scripts/build-templates.js b/packages/openui-cli/scripts/build-templates.js
index f12d9b083..03f5b7126 100644
--- a/packages/openui-cli/scripts/build-templates.js
+++ b/packages/openui-cli/scripts/build-templates.js
@@ -2,22 +2,22 @@ const fs = require("node:fs");
const path = require("node:path");
const { rimrafSync } = require("rimraf");
-const srcDir = path.resolve(__dirname, "../src/templates/openui-chat");
-const destDir = path.resolve(__dirname, "../dist/templates/openui-chat");
+const TEMPLATES = ["openui-chat", "openui-cloud"];
-if (!fs.existsSync(srcDir)) {
- throw new Error(`Template source directory not found: ${srcDir}`);
-}
+for (const template of TEMPLATES) {
+ const srcDir = path.resolve(__dirname, "../src/templates", template);
+ const destDir = path.resolve(__dirname, "../dist/templates", template);
+
+ if (!fs.existsSync(srcDir)) {
+ throw new Error(`Template source directory not found: ${srcDir}`);
+ }
-// Equivalent to: rm -rf dist/templates/openui-chat
-rimrafSync(destDir);
+ // Equivalent to: rm -rf dist/templates/
+ fs.rmSync(destDir, { recursive: true, force: true });
-// Equivalent to: mkdir -p dist/templates
-fs.mkdirSync(path.dirname(destDir), {
- recursive: true,
-});
+ // Equivalent to: mkdir -p dist/templates
+ fs.mkdirSync(path.dirname(destDir), { recursive: true });
-// Equivalent to: cp -R src/templates/openui-chat dist/templates/openui-chat
-fs.cpSync(srcDir, destDir, {
- recursive: true,
-});
+ // Equivalent to: cp -R src/templates/ dist/templates/
+ fs.cpSync(srcDir, destDir, { recursive: true });
+}
diff --git a/packages/openui-cli/src/auth/authenticator.ts b/packages/openui-cli/src/auth/authenticator.ts
new file mode 100644
index 000000000..079fd53ba
--- /dev/null
+++ b/packages/openui-cli/src/auth/authenticator.ts
@@ -0,0 +1,149 @@
+import http from "node:http";
+
+// openid-client's Configuration type is ESM-only; keep it opaque in this CJS build.
+type Configuration = any;
+
+export interface AuthConfig {
+ issuerUrl: string;
+ clientId: string;
+ redirectUri?: string;
+ scopes?: string[];
+}
+
+export interface AuthResult {
+ accessToken: string;
+ refreshToken?: string;
+ idToken?: string;
+ userInfo?: Record;
+}
+
+const SUCCESS_HTML = `Signed in ✓ Signed in You can close this tab and return to your terminal.
`;
+
+const errorHtml = (msg: string) =>
+ `Sign-in failed Sign-in failed ${msg}
Return to your terminal and try again.
`;
+
+/** OAuth 2.0 + PKCE via a local loopback callback. ESM-only deps load via dynamic import(). */
+export class Authenticator {
+ private readonly config: AuthConfig & { redirectUri: string; scopes: string[] };
+ private clientConfig?: Configuration;
+ private codeVerifier?: string;
+
+ constructor(config: AuthConfig) {
+ this.config = {
+ redirectUri: "http://localhost:0/cb", // 0 = any free port
+ scopes: ["openid", "profile", "email"],
+ ...config,
+ };
+ }
+
+ getClientConfig(): Configuration {
+ if (!this.clientConfig) {
+ throw new Error("Client not initialized. Call initialize() first.");
+ }
+ return this.clientConfig;
+ }
+
+ async initialize(): Promise {
+ const { discovery } = await import("openid-client");
+ this.clientConfig = await discovery(new URL(this.config.issuerUrl), this.config.clientId);
+ }
+
+ async authenticate(): Promise {
+ if (!this.clientConfig) {
+ throw new Error("Client not initialized. Call initialize() first.");
+ }
+ const { randomPKCECodeVerifier, calculatePKCECodeChallenge } = await import("openid-client");
+ this.codeVerifier = randomPKCECodeVerifier();
+ const codeChallenge = await calculatePKCECodeChallenge(this.codeVerifier);
+ return this.handleBrowserAuth(codeChallenge);
+ }
+
+ private async handleBrowserAuth(codeChallenge: string): Promise {
+ const { authorizationCodeGrant, buildAuthorizationUrl } = await import("openid-client");
+ const { default: open } = await import("open");
+
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ let actualPort = 0;
+ let timerId: null | NodeJS.Timeout = null
+ const finish = (run: () => void) => {
+ if (settled) return;
+ settled = true;
+ if(timerId) clearTimeout(timerId);
+ server.close();
+ run();
+ };
+
+ const server = http.createServer(async (req, res) => {
+ if (!req.url?.startsWith("/cb")) {
+ res.writeHead(404, { Connection: "close" });
+ res.end("Not found");
+ return;
+ }
+ try {
+ const callbackUrl = new URL(req.url, `http://localhost:${actualPort}`);
+ if (!this.clientConfig || !this.codeVerifier) {
+ throw new Error("Client not properly initialized");
+ }
+ const tokens = await authorizationCodeGrant(this.clientConfig, callbackUrl, {
+ pkceCodeVerifier: this.codeVerifier,
+ });
+ let userInfo: Record | undefined;
+ try {
+ const claims = tokens.claims();
+ if (claims) userInfo = claims as Record;
+ } catch {
+ /* no id_token claims */
+ }
+ res.writeHead(200, { "Content-Type": "text/html", Connection: "close" });
+ res.end(SUCCESS_HTML);
+ finish(() =>
+ resolve({
+ accessToken: tokens.access_token ?? "",
+ refreshToken: tokens.refresh_token,
+ idToken: tokens.id_token,
+ userInfo,
+ }),
+ );
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : "Unknown error";
+ res.writeHead(200, { "Content-Type": "text/html", Connection: "close" });
+ res.end(errorHtml(msg));
+ finish(() => reject(new Error(`Token exchange failed: ${msg}`)));
+ }
+ });
+
+ server.on("error", (error) => finish(() => reject(error)));
+
+ server.listen(0, async () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ finish(() => reject(new Error("Failed to bind a local callback port.")));
+ return;
+ }
+ actualPort = address.port;
+ const url = buildAuthorizationUrl(this.clientConfig!, {
+ redirect_uri: `http://localhost:${actualPort}/cb`,
+ scope: this.config.scopes.join(" "),
+ code_challenge: codeChallenge,
+ code_challenge_method: "S256",
+ prompt: "consent",
+ }).toString();
+
+ console.info("\n🌐 Opening your browser to sign in to Thesys…");
+ console.info(` If it doesn't open, visit:\n ${url}\n`);
+ try {
+ await open(url);
+ } catch {
+ /* user opens the URL manually */
+ }
+ console.info("⏳ Waiting for you to finish signing in…");
+ });
+
+ timerId = setTimeout(
+ () => finish(() => reject(new Error("Sign-in timed out after 5 minutes."))),
+ 5 * 60 * 1000,
+ );
+ });
+ }
+}
diff --git a/packages/openui-cli/src/auth/mint.ts b/packages/openui-cli/src/auth/mint.ts
new file mode 100644
index 000000000..bec7d2f19
--- /dev/null
+++ b/packages/openui-cli/src/auth/mint.ts
@@ -0,0 +1,92 @@
+import { Authenticator } from "./authenticator";
+
+// Thesys console OAuth + key mint (same flow as create-c1-app). The OpenUI Cloud
+// master key is the same C1-flavored org API key (usageType "C1").
+const THESYS_API_URL = "https://api.app.thesys.dev";
+const THESYS_ISSUER_URL = "https://api.app.thesys.dev/oidc";
+const THESYS_CLIENT_ID = "create-c1-app"; // public PKCE client (no secret)
+export const THESYS_KEYS_URL = "https://console.thesys.dev/keys";
+
+export type CloudAuthMethod = "oauth" | "manual" | "skip";
+/** How the cloud key was obtained (for telemetry) — auth method + the `--api-key` flag case. */
+export type ResolvedAuthMethod = CloudAuthMethod | "apikey-flag";
+
+/** Sign in via the browser and mint an OpenUI Cloud API key for the user's org. */
+export async function mintCloudApiKey(projectName: string): Promise {
+ const auth = new Authenticator({ issuerUrl: THESYS_ISSUER_URL, clientId: THESYS_CLIENT_ID });
+ await auth.initialize();
+ const { accessToken, userInfo } = await auth.authenticate();
+
+ const { fetchUserInfo } = await import("openid-client");
+ const profile = await fetchUserInfo(
+ auth.getClientConfig(),
+ accessToken,
+ (userInfo?.["sub"] as string | undefined) ?? "",
+ );
+ const orgId = (profile["org_claims"] as { orgId: string }[] | undefined)?.[0]?.orgId;
+ if (!orgId) {
+ throw new Error(`No organization found for your account. Create a key at ${THESYS_KEYS_URL}.`);
+ }
+
+ console.info("🔑 Creating an OpenUI Cloud API key…");
+ const res = await fetch(`${THESYS_API_URL}/application/application.createApiKeyWithOidc`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify({ name: projectName || "OpenUI Cloud App", orgId, usageType: "C1" }),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to create API key (HTTP ${res.status}): ${await res.text()}`);
+ }
+ const data = (await res.json()) as { apiKey?: string };
+ if (!data.apiKey) throw new Error("The server did not return an API key.");
+ return data.apiKey;
+}
+
+/**
+ * Resolve a cloud API key by the chosen method: an explicitly provided key, a
+ * browser OAuth mint, a manual paste, or skip (null → leave the .env slot empty).
+ */
+export async function resolveCloudApiKey(opts: {
+ apiKey?: string;
+ auth?: CloudAuthMethod;
+ projectName: string;
+ interactive: boolean;
+}): Promise<{ key: string | null; method: ResolvedAuthMethod }> {
+ const provided = opts.apiKey?.trim();
+ if (provided) return { key: provided, method: "apikey-flag" };
+
+ let method = opts.auth;
+ if (!method) {
+ if (!opts.interactive) {
+ throw new Error(
+ `An API key is required in non-interactive mode. Pass --api-key ` +
+ `(get one at ${THESYS_KEYS_URL}).`,
+ );
+ }
+ const { select } = await import("@inquirer/prompts");
+ method = (await select({
+ message: "Connect to OpenUI Cloud:",
+ choices: [
+ { name: "Sign in with Thesys (opens a browser, mints a key)", value: "oauth" },
+ { name: "Paste an existing API key", value: "manual" },
+ { name: "Skip — add THESYS_API_KEY to .env later", value: "skip" },
+ ],
+ })) as CloudAuthMethod;
+ }
+
+ if (method === "skip") return { key: null, method: "skip" };
+
+ if (method === "manual") {
+ const { password } = await import("@inquirer/prompts");
+ const key = (
+ await password({ message: "Paste your OpenUI Cloud API key:", mask: true })
+ ).trim();
+ return { key: key || null, method: "manual" };
+ }
+
+ return { key: await mintCloudApiKey(opts.projectName), method: "oauth" };
+}
diff --git a/packages/openui-cli/src/commands/create-app.ts b/packages/openui-cli/src/commands/create-app.ts
new file mode 100644
index 000000000..982d5ea4c
--- /dev/null
+++ b/packages/openui-cli/src/commands/create-app.ts
@@ -0,0 +1,247 @@
+import { execSync } from "node:child_process";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+
+import { resolveCloudApiKey, THESYS_KEYS_URL, type CloudAuthMethod } from "../auth/mint";
+import { detectPackageManager } from "../lib/detect-package-manager";
+import { runSkillInstall, shouldInstallSkill } from "../lib/install-skill";
+import { resolveArgs } from "../lib/resolve-args";
+import { CreateError, telemetry } from "../lib/telemetry";
+
+export type TemplateName = "openui-chat" | "openui-cloud";
+
+export interface CreateAppOptions {
+ name?: string;
+ template?: TemplateName;
+ skill?: boolean;
+ noInteractive?: boolean;
+ noInstall?: boolean;
+ // cloud-only
+ apiKey?: string;
+ auth?: CloudAuthMethod;
+}
+
+// The hosted OpenUI Cloud /v1 host the scaffolded cloud app talks to.
+const OPENUI_CLOUD_BASE_URL = "https://api.thesys.dev";
+
+function shouldCopyTemplatePath(templateDir: string, src: string): boolean {
+ const rel = path.relative(templateDir, src);
+ if (!rel) return true;
+ const top = rel.split(path.sep)[0] ?? "";
+ // never copy install/build artifacts that may sit in a template dir
+ return !["node_modules", ".next", ".turbo", "dist"].includes(top);
+}
+
+export async function runCreateApp(options: CreateAppOptions): Promise {
+ const interactive = !options.noInteractive;
+ const t0 = Date.now();
+ telemetry.register({ is_interactive: interactive });
+
+ const args = await resolveArgs(
+ {
+ name: options.name
+ ? { value: options.name }
+ : { prompt: { type: "input", message: "Project name?" }, required: true },
+ template: options.template
+ ? { value: options.template }
+ : {
+ prompt: {
+ type: "select",
+ message: "Which template?",
+ choices: [
+ { value: "openui-chat", name: "OpenUI Chat — bring your own model key (OpenAI)" },
+ {
+ value: "openui-cloud",
+ name: "OpenUI Cloud — managed conversations, artifacts & streaming",
+ },
+ ],
+ },
+ required: true,
+ },
+ },
+ interactive,
+ );
+
+ const { name, template } = args as { name: string; template: TemplateName };
+ telemetry.register({ template });
+ telemetry.capture("cli_create_started", { interactive });
+ telemetry.capture("cli_template_selected", { template });
+
+ const targetDir = path.resolve(process.cwd(), name);
+ if (fs.existsSync(targetDir)) {
+ throw new CreateError("dir_exists", `Directory "${name}" already exists.`);
+ }
+
+ const runner = detectPackageManager();
+ telemetry.register({ package_manager: runner });
+ const templateDir = path.join(__dirname, "..", "templates", template);
+ if (!fs.existsSync(templateDir)) {
+ throw new CreateError(
+ "template_missing",
+ `Template "${template}" not found. Rebuild the CLI with \`pnpm build\`.`,
+ );
+ }
+
+ console.info(`\nScaffolding ${template} into "${name}"...\n`);
+ fs.cpSync(templateDir, targetDir, {
+ recursive: true,
+ filter: (src) => shouldCopyTemplatePath(templateDir, src),
+ });
+
+ // package.json: set the project name and de-vendor monorepo-local deps
+ // (workspace:* / file: / catalog:) to the published "latest". link: deps are
+ // rewritten to an absolute file: path so locally-linked packages (e.g.
+ // @openuidev/thesys) keep resolving against the developer's checkout under any
+ // package manager — npm rejects link:, and ~ isn't expanded. Temporary, until
+ // these packages are published.
+ const pkgPath = path.join(targetDir, "package.json");
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as {
+ name: string;
+ dependencies?: Record;
+ devDependencies?: Record;
+ };
+ pkg.name = name;
+ for (const section of ["dependencies", "devDependencies"] as const) {
+ const deps = pkg[section];
+ if (!deps) continue;
+ for (const key of Object.keys(deps)) {
+ const v = deps[key];
+ if (!v) continue;
+ if (v.startsWith("link:")) {
+ const target = v.slice("link:".length);
+ const abs = target.startsWith("~")
+ ? path.join(os.homedir(), target.slice(1))
+ : path.resolve(target);
+ deps[key] = `file:${abs}`;
+ continue;
+ }
+ // workspace:/file:/catalog: are monorepo-only protocols npm/yarn/bun
+ // can't resolve standalone — pin them to the published "latest".
+ if (/^(workspace:|file:|catalog:)/.test(v)) deps[key] = "latest";
+ }
+ }
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
+
+ const installCmd =
+ runner === "pnpm dlx"
+ ? "pnpm install"
+ : runner === "yarn dlx"
+ ? "yarn"
+ : runner === "bunx"
+ ? "bun install"
+ : "npm install";
+
+ if (options.noInstall) {
+ console.info(`Skipping dependency install (--no-install). Run \`${installCmd}\` later.\n`);
+ } else {
+ console.info(`Installing dependencies with: ${installCmd}\n`);
+ try {
+ execSync(installCmd, { stdio: "inherit", cwd: targetDir });
+ } catch {
+ throw new CreateError("install_deps", "dependency install failed");
+ }
+ }
+
+ const installSkill = await shouldInstallSkill(options.skill, interactive);
+ telemetry.capture("cli_skill_installed", { installed: installSkill });
+ if (installSkill) runSkillInstall(targetDir);
+
+ const envWritten =
+ template === "openui-cloud"
+ ? await writeCloudEnv(targetDir, name, options, interactive)
+ : await writeChatEnv(targetDir, interactive);
+
+ const devCmd =
+ runner === "pnpm dlx"
+ ? "pnpm"
+ : runner === "yarn dlx"
+ ? "yarn"
+ : runner === "bunx"
+ ? "bun"
+ : "npm";
+
+ telemetry.capture("cli_create_succeeded", {
+ template,
+ duration_ms: Date.now() - t0,
+ skill_installed: installSkill,
+ env_written: envWritten,
+ });
+ console.info(
+ getStartedMessage({ name, devCmd, template, skillInstalled: installSkill, envWritten }),
+ );
+}
+
+async function writeChatEnv(targetDir: string, interactive: boolean): Promise {
+ if (!interactive) return false;
+ const { input } = await import("@inquirer/prompts");
+ const apiKey = (
+ await input({ message: "Enter your OpenAI API key (leave blank to skip):" })
+ ).trim();
+ if (!apiKey) return false;
+ fs.writeFileSync(path.join(targetDir, ".env"), `OPENAI_API_KEY=${apiKey}\n`);
+ return true;
+}
+
+async function writeCloudEnv(
+ targetDir: string,
+ name: string,
+ options: CreateAppOptions,
+ interactive: boolean,
+): Promise {
+ let apiKey: string | null = null;
+ try {
+ const resolved = await resolveCloudApiKey({
+ apiKey: options.apiKey,
+ auth: options.auth,
+ projectName: name,
+ interactive,
+ });
+ apiKey = resolved.key;
+ telemetry.capture("cli_cloud_auth_method", {
+ method: resolved.method,
+ succeeded: apiKey != null,
+ });
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(`\n⚠ Could not obtain an API key: ${msg}`);
+ console.error(` Add THESYS_API_KEY to .env later (keys: ${THESYS_KEYS_URL}).\n`);
+ }
+ const lines = [
+ `THESYS_API_KEY=${apiKey ?? ""}`,
+ `OPENUI_CLOUD_BASE_URL=${OPENUI_CLOUD_BASE_URL}`,
+ `NEXT_PUBLIC_OPENUI_CLOUD_BASE_URL=${OPENUI_CLOUD_BASE_URL}`,
+ ];
+ fs.writeFileSync(path.join(targetDir, ".env"), lines.join("\n") + "\n");
+ return apiKey != null;
+}
+
+function getStartedMessage(o: {
+ name: string;
+ devCmd: string;
+ template: TemplateName;
+ skillInstalled: boolean;
+ envWritten: boolean;
+}): string {
+ const skillMessage = o.skillInstalled
+ ? "The OpenUI agent skill was installed.\nAI coding assistants will use it to help you build with OpenUI.\n"
+ : "";
+
+ const envNote =
+ o.template === "openui-cloud"
+ ? o.envWritten
+ ? "✅ .env created with your OpenUI Cloud API key + base URL."
+ : `⚠ .env created without a key. Add THESYS_API_KEY=… (get one at ${THESYS_KEYS_URL}).`
+ : o.envWritten
+ ? "✅ .env created with your API key."
+ : "Add your API key to .env:\nOPENAI_API_KEY=sk-your-key-here";
+
+ return `${skillMessage}
+Done!
+
+${envNote}
+
+> cd ${o.name}
+> ${o.devCmd} run dev
+`;
+}
diff --git a/packages/openui-cli/src/commands/create-chat-app.ts b/packages/openui-cli/src/commands/create-chat-app.ts
deleted file mode 100644
index 556b340e6..000000000
--- a/packages/openui-cli/src/commands/create-chat-app.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import { execSync } from "child_process";
-import * as fs from "fs";
-import * as path from "path";
-
-import { detectPackageManager } from "../lib/detect-package-manager";
-import { runSkillInstall, shouldInstallSkill } from "../lib/install-skill";
-import { resolveArgs } from "../lib/resolve-args";
-
-export interface CreateChatAppOptions {
- name?: string;
- skill?: boolean;
- noInteractive?: boolean;
-}
-
-function shouldCopyTemplatePath(templateDir: string, src: string): boolean {
- const relativePath = path.relative(templateDir, src);
-
- if (!relativePath) return true;
-
- return relativePath !== "openui-chat" && !relativePath.startsWith(`openui-chat${path.sep}`);
-}
-
-export async function runCreateChatApp(options: CreateChatAppOptions): Promise {
- const args = await resolveArgs(
- {
- name: options.name
- ? { value: options.name }
- : {
- prompt: { type: "input", message: "Project name?" },
- required: true,
- },
- },
- !options.noInteractive,
- );
-
- const { name } = args as { name: string };
- const targetDir = path.resolve(process.cwd(), name);
-
- if (fs.existsSync(targetDir)) {
- console.error(`Error: Directory "${name}" already exists.`);
- process.exit(1);
- }
-
- const runner = detectPackageManager();
-
- const templateDir = path.join(__dirname, "..", "templates", "openui-chat");
- if (!fs.existsSync(templateDir)) {
- console.error("Error: Template not found. Please rebuild the CLI with `pnpm build`.");
- process.exit(1);
- }
-
- console.info(`\nScaffolding OpenUI Chat app into "${name}"...\n`);
-
- const nestedTemplateDir = path.join(templateDir, "openui-chat");
- if (fs.existsSync(nestedTemplateDir)) {
- console.warn("Warning: Ignoring nested template directory left by a previous CLI build.");
- }
-
- fs.cpSync(templateDir, targetDir, {
- recursive: true,
- filter: (src) => shouldCopyTemplatePath(templateDir, src),
- });
-
- const pkgPath = path.join(targetDir, "package.json");
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as {
- name: string;
- dependencies?: Record;
- devDependencies?: Record;
- };
- pkg.name = name;
- for (const section of ["dependencies", "devDependencies"] as const) {
- for (const key of Object.keys(pkg[section] ?? {})) {
- if (pkg[section]![key] === "workspace:*") {
- pkg[section]![key] = "latest";
- }
- }
- }
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
-
- const installCmd =
- runner === "pnpm dlx"
- ? "pnpm install"
- : runner === "yarn dlx"
- ? "yarn"
- : runner === "bunx"
- ? "bun install"
- : "npm install";
-
- console.info(`Installing dependencies with: ${installCmd}\n`);
-
- try {
- execSync(installCmd, { stdio: "inherit", cwd: targetDir });
- } catch {
- console.error("\nFailed to install dependencies.");
- process.exit(1);
- }
-
- const installSkill = await shouldInstallSkill(options.skill, !options.noInteractive);
- if (installSkill) {
- runSkillInstall(targetDir);
- }
-
- const devCmd =
- runner === "pnpm dlx"
- ? "pnpm"
- : runner === "yarn dlx"
- ? "yarn"
- : runner === "bunx"
- ? "bun"
- : "npm";
-
- let apiKeyWritten = false;
- if (!options.noInteractive) {
- const { input } = await import("@inquirer/prompts");
- const apiKey = (
- await input({ message: "Enter your OpenAI API key (leave blank to skip):" })
- ).trim();
-
- if (apiKey) {
- fs.writeFileSync(path.join(targetDir, ".env"), `OPENAI_API_KEY=${apiKey}\n`);
- apiKeyWritten = true;
- }
- }
-
- console.info(getStartedMessage(name, devCmd, installSkill, apiKeyWritten));
-}
-
-const getStartedMessage = (
- name: string,
- devCmd: string,
- skillInstalled: boolean,
- apiKeyWritten: boolean,
-) => {
- const envInstructions = apiKeyWritten
- ? "✅ .env file created with your API key."
- : `touch .env
-
-Add your API key to .env:
-OPENAI_API_KEY=sk-your-key-here`;
-
- const skillMessage = skillInstalled
- ? "The OpenUI agent skill was installed.\nAI coding assistants will use it to help you build with OpenUI."
- : "";
-
- return `${skillMessage}
-
-Done!
-Get started:
-
-${envInstructions}
-
-> cd ${name}
-> ${devCmd} run dev
-`;
-};
diff --git a/packages/openui-cli/src/commands/generate.ts b/packages/openui-cli/src/commands/generate.ts
index f6dab897b..2f6c86a29 100644
--- a/packages/openui-cli/src/commands/generate.ts
+++ b/packages/openui-cli/src/commands/generate.ts
@@ -2,6 +2,8 @@ import { execFileSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
+import { CreateError, telemetry } from "../lib/telemetry";
+
export interface GenerateOptions {
out?: string;
jsonSchema?: boolean;
@@ -10,11 +12,15 @@ export interface GenerateOptions {
}
export async function runGenerate(entry: string, options: GenerateOptions): Promise {
+ const t0 = Date.now();
+ telemetry.capture("cli_generate_started", {
+ json_schema: !!options.jsonSchema,
+ out_to_file: !!options.out,
+ });
const entryPath = path.resolve(process.cwd(), entry);
if (!fs.existsSync(entryPath)) {
- console.error(`Error: File not found: ${entryPath}`);
- process.exit(1);
+ throw new CreateError("generate_entry_missing", `File not found: ${entryPath}`);
}
const workerPath = path.join(__dirname, "generate-worker.js");
@@ -32,8 +38,7 @@ export async function runGenerate(entry: string, options: GenerateOptions): Prom
stdio: ["inherit", "pipe", "inherit"],
});
} catch (err) {
- console.error(err);
- process.exit(1);
+ throw new CreateError("generate_worker", err instanceof Error ? err.message : String(err));
}
if (options.out) {
@@ -44,4 +49,10 @@ export async function runGenerate(entry: string, options: GenerateOptions): Prom
} else {
process.stdout.write(output + "\n");
}
+
+ telemetry.capture("cli_generate_succeeded", {
+ json_schema: !!options.jsonSchema,
+ out_to_file: !!options.out,
+ duration_ms: Date.now() - t0,
+ });
}
diff --git a/packages/openui-cli/src/index.ts b/packages/openui-cli/src/index.ts
index 43a2140c5..68486d165 100644
--- a/packages/openui-cli/src/index.ts
+++ b/packages/openui-cli/src/index.ts
@@ -1,29 +1,71 @@
#!/usr/bin/env node
+import * as fs from "node:fs";
+import * as path from "node:path";
+
import { Command } from "commander";
-import { runCreateChatApp } from "./commands/create-chat-app";
+import { runCreateApp } from "./commands/create-app";
import { runGenerate } from "./commands/generate";
import { resolveArgs } from "./lib/resolve-args";
+import { telemetry } from "./lib/telemetry";
+import { handleCliError, normalizeAuth, normalizeTemplate } from "./lib/utils"; // Ensure utils.ts is included for type declarations
const program = new Command();
-program.name("openui").description("CLI for OpenUI").version("0.0.6");
+const cliVersion = (
+ JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")) as {
+ version: string;
+ }
+).version;
+
+program.name("openui").description("CLI for OpenUI").version(cliVersion);
+program.option("--no-telemetry", "Disable anonymous usage analytics");
+
+// Init telemetry once, just before any command runs (honors --no-telemetry / DO_NOT_TRACK).
+program.hook("preAction", (_thisCommand, actionCommand) => {
+ telemetry.init({ cliVersion, flagEnabled: program.opts()["telemetry"] !== false });
+ telemetry.capture("cli_invoked", { command: actionCommand.name() });
+});
program
.command("create")
- .description("Scaffold a new Next.js app with OpenUI Chat")
+ .description("Scaffold a new Next.js app (OpenUI Chat or OpenUI Cloud)")
.option("-n, --name ", "Project name")
+ .option("-t, --template ", "Template: openui-chat | openui-cloud")
+ .option("--api-key ", "OpenUI Cloud API key (cloud template; skips sign-in)")
+ .option("--auth ", "Cloud auth method: oauth | manual | skip")
.option("--skill", "Install the OpenUI agent skill for AI coding assistants")
.option("--no-skill", "Skip installing the OpenUI agent skill")
.option("--no-interactive", "Fail with error if required args are missing")
- .action(async (options: { name?: string; skill?: boolean; interactive: boolean }) => {
- await runCreateChatApp({
- name: options.name,
- skill: options.skill,
- noInteractive: !options.interactive,
- });
- });
+ .option("--no-install", "Scaffold without running the package install")
+ .action(
+ async (options: {
+ name?: string;
+ template?: string;
+ apiKey?: string;
+ auth?: string;
+ skill?: boolean;
+ interactive: boolean;
+ install: boolean;
+ }) => {
+ try {
+ await runCreateApp({
+ name: options.name,
+ template: normalizeTemplate(options.template),
+ apiKey: options.apiKey,
+ auth: normalizeAuth(options.auth),
+ skill: options.skill,
+ noInteractive: !options.interactive,
+ noInstall: !options.install,
+ });
+ } catch (e) {
+ handleCliError(e, "cli_create_failed");
+ } finally {
+ await telemetry.shutdown();
+ }
+ },
+ );
program
.command("generate")
@@ -51,19 +93,25 @@ program
interactive: boolean;
},
) => {
- const args = await resolveArgs(
- {
- entry: entry
- ? { value: entry }
- : {
- prompt: { type: "input", message: "Entry file path?" },
- required: true,
- },
- },
- options.interactive,
- );
+ try {
+ const args = await resolveArgs(
+ {
+ entry: entry
+ ? { value: entry }
+ : {
+ prompt: { type: "input", message: "Entry file path?" },
+ required: true,
+ },
+ },
+ options.interactive,
+ );
- await runGenerate((args as { entry: string }).entry, options);
+ await runGenerate((args as { entry: string }).entry, options);
+ } catch (e) {
+ handleCliError(e, "cli_generate_failed");
+ } finally {
+ await telemetry.shutdown();
+ }
},
);
diff --git a/packages/openui-cli/src/lib/telemetry.ts b/packages/openui-cli/src/lib/telemetry.ts
new file mode 100644
index 000000000..463e0507a
--- /dev/null
+++ b/packages/openui-cli/src/lib/telemetry.ts
@@ -0,0 +1,142 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { PostHog } from "posthog-node";
+
+// Public ingestion key (same project as docs/coda-prod). Overridable for testing.
+const POSTHOG_KEY =
+ process.env["OPENUI_POSTHOG_KEY"] ?? "phc_3OLW53x09ZTVZSV6BEpj5uycj3ooqR6KOemOjx04e3D";
+const POSTHOG_HOST = process.env["OPENUI_POSTHOG_HOST"] ?? "https://us.i.posthog.com";
+const SHUTDOWN_TIMEOUT_MS = 2000;
+
+const isTruthyEnv = (v?: string) => v === "1" || v?.toLowerCase() === "true";
+const configDir = () =>
+ path.join(process.env["XDG_CONFIG_HOME"] ?? path.join(os.homedir(), ".config"), "openui");
+const isCi = () => {
+ const e = process.env;
+ return isTruthyEnv(e["CI"]) || !!e["GITHUB_ACTIONS"] || !!e["GITLAB_CI"] || !!e["BUILDKITE"];
+};
+
+type Stored = { firstRunNoticeShown?: boolean };
+
+function loadOrCreateState() {
+ const file = path.join(configDir(), "telemetry.json");
+ try {
+ const raw = JSON.parse(fs.readFileSync(file, "utf8")) as Stored;
+ return {
+ isFirstRun: !raw.firstRunNoticeShown,
+ persist: () => writeState(file, { ...raw, firstRunNoticeShown: true }),
+ };
+ } catch {
+ /* missing/corrupt → create */
+ }
+ const fresh: Stored = { firstRunNoticeShown: false };
+ return {
+ isFirstRun: true,
+ persist: () => writeState(file, { ...fresh, firstRunNoticeShown: true }),
+ };
+}
+function writeState(file: string, s: Stored) {
+ try {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, JSON.stringify(s));
+ } catch {
+ /* read-only fs / CI: best-effort */
+ }
+}
+
+/** Thrown by command funnels so the index wrapper can attribute the failure stage + drain once. */
+export class CreateError extends Error {
+ constructor(
+ public stage: string,
+ message: string,
+ ) {
+ super(message);
+ this.name = "CreateError";
+ }
+}
+
+export class Telemetry {
+ private client?: PostHog;
+ private distinctId = "anonymous";
+ private superProps: Record = {};
+ private enabled = false;
+
+ init(opts: { cliVersion: string; flagEnabled: boolean }) {
+ const optedOut =
+ isTruthyEnv(process.env["DO_NOT_TRACK"]) ||
+ isTruthyEnv(process.env["OPENUI_TELEMETRY_DISABLED"]) ||
+ opts.flagEnabled === false;
+ if (optedOut) return; // enabled stays false → all capture() are no-ops
+ const state = loadOrCreateState();
+ this.superProps = {
+ cli_version: opts.cliVersion,
+ os: process.platform,
+ os_release: os.release(),
+ arch: process.arch,
+ node_version: process.version,
+ ci: isCi(),
+ };
+ try {
+ this.client = new PostHog(POSTHOG_KEY, {
+ host: POSTHOG_HOST,
+ flushAt: 1,
+ flushInterval: 0,
+ });
+ // Telemetry is best-effort: swallow network/flush errors so an offline CLI
+ // run never spams the user's console with PostHog stack traces.
+ this.client.on("error", () => {});
+ } catch {
+ return;
+ }
+ this.enabled = true;
+ // posthog-core logs flush failures via a hardcoded console.error (not gated on
+ // any logger/option). Filter ONLY those lines so an offline run stays quiet —
+ // the CLI's own console.error output passes through untouched.
+ const origError = console.error.bind(console);
+ console.error = (...args: unknown[]) => {
+ if (typeof args[0] === "string" && args[0].includes("flushing PostHog")) return;
+ origError(...args);
+ };
+ if (process.env["OPENUI_TELEMETRY_DEBUG"] === "1") this.client.debug();
+ if (state.isFirstRun) {
+ process.stderr.write(
+ "\n◆ OpenUI CLI collects anonymous usage analytics to improve the tool.\n" +
+ " No code, prompts, keys, or personal data are collected.\n" +
+ " Opt out anytime: set DO_NOT_TRACK=1 or pass --no-telemetry.\n\n",
+ );
+ state.persist();
+ }
+ }
+
+ register(props: Record) {
+ if (this.enabled) Object.assign(this.superProps, props);
+ }
+
+ capture(event: string, properties: Record = {}) {
+ if (!this.enabled || !this.client) return;
+ try {
+ this.client.capture({
+ distinctId: this.distinctId,
+ event,
+ properties: { ...this.superProps, ...properties },
+ });
+ } catch {
+ /* telemetry must never throw */
+ }
+ }
+
+ async shutdown() {
+ if (!this.enabled || !this.client) return;
+ try {
+ await Promise.race([
+ this.client.shutdown(),
+ new Promise((r) => setTimeout(r, SHUTDOWN_TIMEOUT_MS)),
+ ]);
+ } catch {
+ /* swallow */
+ }
+ }
+}
+
+export const telemetry = new Telemetry();
diff --git a/packages/openui-cli/src/lib/utils.ts b/packages/openui-cli/src/lib/utils.ts
new file mode 100644
index 000000000..c59467c76
--- /dev/null
+++ b/packages/openui-cli/src/lib/utils.ts
@@ -0,0 +1,30 @@
+import { CloudAuthMethod } from "../auth/mint";
+import { TemplateName } from "../commands/create-app";
+import { CreateError, Telemetry } from "./telemetry";
+
+export function handleCliError(e: unknown, event: string, telemetry?: Telemetry): void {
+ const known = e instanceof CreateError;
+ const message = e instanceof Error ? e.message : String(e);
+ console.error(known ? `Error: ${message}` : message);
+
+ if (telemetry) {
+ telemetry.capture(event, { stage: known ? e.stage : "unknown", error: message.slice(0, 200) });
+ }
+
+ process.exitCode = 1;
+}
+
+export function normalizeTemplate(t?: string): TemplateName | undefined {
+ if (!t) return undefined;
+ const v = t.toLowerCase();
+ if (v === "chat" || v === "openui-chat") return "openui-chat";
+ if (v === "cloud" || v === "openui-cloud") return "openui-cloud";
+ throw new CreateError("bad_args", `unknown template "${t}". Use: openui-chat | openui-cloud.`);
+}
+
+export function normalizeAuth(a?: string): CloudAuthMethod | undefined {
+ if (!a) return undefined;
+ const v = a.toLowerCase();
+ if (v === "oauth" || v === "manual" || v === "skip") return v;
+ throw new CreateError("bad_args", `unknown --auth "${a}". Use: oauth | manual | skip.`);
+}
diff --git a/packages/openui-cli/src/templates/openui-chat/src/app/page.tsx b/packages/openui-cli/src/templates/openui-chat/src/app/page.tsx
index a776fa978..612caddb6 100644
--- a/packages/openui-cli/src/templates/openui-chat/src/app/page.tsx
+++ b/packages/openui-cli/src/templates/openui-chat/src/app/page.tsx
@@ -2,31 +2,34 @@
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 {
+ openAIMessageFormat,
+ openAIReadableStreamAdapter,
+ type ChatLLM,
+} from "@openuidev/react-headless";
+import { AgentInterface } from "@openuidev/react-ui";
import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-lib";
const systemPrompt = openuiLibrary.prompt(openuiPromptOptions);
+const llm: ChatLLM = {
+ send: async ({ messages, signal }) =>
+ fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ systemPrompt,
+ messages: openAIMessageFormat.toApi(messages),
+ }),
+ signal,
+ }),
+ streamProtocol: openAIReadableStreamAdapter(),
+};
+
export default function Home() {
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/packages/openui-cli/src/templates/openui-cloud/.gitignore b/packages/openui-cli/src/templates/openui-cloud/.gitignore
new file mode 100644
index 000000000..3cceb2b4c
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/.gitignore
@@ -0,0 +1,48 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# 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/packages/openui-cli/src/templates/openui-cloud/README.md b/packages/openui-cli/src/templates/openui-cloud/README.md
new file mode 100644
index 000000000..eb78c6c51
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/README.md
@@ -0,0 +1,51 @@
+# 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 `openuiCloud()` (browser → `/v1/conversations` +
+ `/v1/artifacts` via the `x-thesys-frontend-token` header, single-flight refresh
+ - 401 retry) and the presentation/report artifact renderers
+ (`Presentation`/`Report` from `@openuidev/thesys`).
+
+## Setup
+
+```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 component library (`chatLibrary`, `Presentation`,
+ `Report`) used by the client page and artifact renderers.
+- `@openuidev/react-headless` / `@openuidev/react-ui` — the chat UI runtime
+ (`AgentInterface`, storage/stream contracts, `defineArtifactRenderer`).
diff --git a/packages/openui-cli/src/templates/openui-cloud/eslint.config.mjs b/packages/openui-cli/src/templates/openui-cloud/eslint.config.mjs
new file mode 100644
index 000000000..05e726d1b
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/eslint.config.mjs
@@ -0,0 +1,18 @@
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+import nextTs from "eslint-config-next/typescript";
+
+const eslintConfig = defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ // Override default ignores of eslint-config-next.
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ]),
+]);
+
+export default eslintConfig;
diff --git a/packages/openui-cli/src/templates/openui-cloud/next.config.ts b/packages/openui-cli/src/templates/openui-cloud/next.config.ts
new file mode 100644
index 000000000..68a6c64d2
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ output: "standalone",
+};
+
+export default nextConfig;
diff --git a/packages/openui-cli/src/templates/openui-cloud/package.json b/packages/openui-cli/src/templates/openui-cloud/package.json
new file mode 100644
index 000000000..4954c6372
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/package.json
@@ -0,0 +1,58 @@
+{
+ "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": "latest",
+ "@openuidev/react-headless": "latest",
+ "@openuidev/react-lang": "latest",
+ "@openuidev/react-ui": "latest",
+ "@openuidev/thesys": "latest",
+ "@openuidev/thesys-server": "latest",
+ "@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": "^4.5.5"
+ },
+ "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/packages/openui-cli/src/templates/openui-cloud/postcss.config.mjs b/packages/openui-cli/src/templates/openui-cloud/postcss.config.mjs
new file mode 100644
index 000000000..61e36849c
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts b/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts
new file mode 100644
index 000000000..b324528cc
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts
@@ -0,0 +1,100 @@
+import { envOr, requiredEnv } from "@/lib/env";
+import { artifactTool, createResponsesInstructions } from "@openuidev/thesys-server";
+import OpenAI from "openai";
+import type { ResponseInputItem } from "openai/resources/responses/responses";
+
+/**
+ * Generation plane: browser → THIS route → OpenUI Cloud.
+ *
+ * Calls the hosted Responses API (`POST /v1/embed/responses`) with the stock
+ * OpenAI SDK — the endpoint speaks the Responses protocol — and proxies the SSE
+ * stream straight to the browser, where `openAIResponsesAdapter` parses it
+ * (including the custom `response.artifact_call.delta` events).
+ *
+ * The artifact tool runs **server-side** inside OpenUI Cloud, so this route is a
+ * pure pipe: there is no client-side tool loop. Reads/edits go browser → /v1/*
+ * with the fct_ token (see /api/frontend-token + the storage adapter).
+ */
+export async function POST(req: Request) {
+ const { threadId, input } = (await req.json()) as {
+ threadId?: string;
+ input?: ResponseInputItem[];
+ };
+
+ 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 },
+ );
+ }
+
+ const client = new OpenAI({
+ baseURL: `${requiredEnv("OPENUI_CLOUD_BASE_URL")}/v1/embed`,
+ apiKey: requiredEnv("THESYS_API_KEY"), // sent as Authorization: Bearer …
+ });
+
+ let stream: AsyncIterable>;
+ try {
+ stream = (await client.responses.create(
+ {
+ model: envOr("OPENUI_MODEL", "anthropic/claude-sonnet-4.6"),
+ conversation: threadId, // store:true persists to the conversation
+ input,
+ stream: true,
+ store: true,
+ tools: [
+ artifactTool({ artifacts: ["slides", "report"] }),
+ {
+ type: "web_search",
+ },
+ {
+ type: "image_search",
+ },
+ ],
+ instructions: createResponsesInstructions(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any,
+ { signal: req.signal }, // propagate browser aborts (stop button / tab close)
+ )) as unknown as AsyncIterable>;
+ } catch (err) {
+ // The SDK surfaces upstream HTTP errors (e.g. 403) as APIError.
+ const e = err as { status?: number; error?: unknown; message?: string };
+ return Response.json(
+ { error: e.error ?? { message: e.message ?? "upstream error" } },
+ { status: e.status ?? 502 },
+ );
+ }
+
+ // Re-emit each SDK event as SSE for the browser adapter.
+ const encoder = new TextEncoder();
+ const body = new ReadableStream({
+ async start(controller) {
+ try {
+ for await (const event of stream) {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify({ type: "error", message })}\n\n`),
+ );
+ } finally {
+ controller.close();
+ }
+ },
+ });
+
+ return new Response(body, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ },
+ });
+}
diff --git a/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts b/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts
new file mode 100644
index 000000000..ee95cb50b
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts
@@ -0,0 +1,34 @@
+import { envOr, 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(`${requiredEnv("OPENUI_CLOUD_BASE_URL")}/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/packages/openui-cli/src/templates/openui-cloud/src/app/globals.css b/packages/openui-cli/src/templates/openui-cloud/src/app/globals.css
new file mode 100644
index 000000000..2e9dad4e0
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/src/app/globals.css
@@ -0,0 +1,23 @@
+@import "tailwindcss";
+
+/*
+ * Integration shim (openui abhishek/openui-chat): the DetailedViewPanel that
+ * hosts an opened artifact is `display:block; flex:0 1 auto`, so inside its
+ * flex-column parent it collapses to content height. genui-sdk's SlideShow /
+ * ReportView use `h-full` and expect the parent to define a height — so the
+ * full-bleed deck shrinks to ~45px (just the controls bar) and the slide
+ * canvas renders as a tiny thumbnail. Make the panel (and the standalone
+ * artifact wrapper) fill the available column so the deck gets real height.
+ * TODO(colleague): DetailedViewPanel should flex:1 / define a height contract
+ * for full-bleed artifact content.
+ */
+.openui-detailed-view-panel {
+ flex: 1 1 0% !important;
+ min-height: 0 !important;
+ display: flex !important;
+ flex-direction: column !important;
+}
+.openui-detailed-view-panel > .thesys-artifact-standalone {
+ flex: 1 1 0% !important;
+ min-height: 0 !important;
+}
diff --git a/packages/openui-cli/src/templates/openui-cloud/src/app/layout.tsx b/packages/openui-cli/src/templates/openui-cloud/src/app/layout.tsx
new file mode 100644
index 000000000..7e44b0451
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/src/app/layout.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from "next";
+import { ThemeProvider } from "@/hooks/use-system-theme";
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: "OpenUI Chat",
+ description: "Generative UI Chat with OpenAI SDK",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/openui-cli/src/templates/openui-cloud/src/app/page.tsx b/packages/openui-cli/src/templates/openui-cloud/src/app/page.tsx
new file mode 100644
index 000000000..35008315b
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/src/app/page.tsx
@@ -0,0 +1,74 @@
+"use client";
+import "@openuidev/react-ui/components.css";
+import "@openuidev/thesys/styles.css";
+
+import {
+ openAIConversationMessageFormat,
+ openAIResponsesAdapter,
+ type ChatLLM,
+ type ChatStorage,
+} from "@openuidev/react-headless";
+import { AgentInterface } from "@openuidev/react-ui";
+// The chat component library the backend's generated programs target.
+import { chatLibrary } from "@openuidev/thesys";
+import { useTheme } from "@/hooks/use-system-theme";
+
+// openuiCloud: one-call browser wiring — a ChatStorage over the /v1 API,
+// authenticated per-request with an fct_ token. The browser hits the API
+// directly; `token` names the backend mint endpoint that issues the fct_.
+import { openuiCloud } from "@/lib/thesys";
+// Artifact renderers: defineArtifactRenderer configs. type 'presentation' |
+// 'report', toolName 'thesys_generate_artifact' (+ 'thesys_edit_artifact').
+import { artifactCategories, artifactRenderers } from "@/lib/artifactRenderers";
+
+const storage: ChatStorage = openuiCloud({
+ // Defaults to https://api.thesys.dev; set NEXT_PUBLIC_OPENUI_CLOUD_BASE_URL to override (e.g. a local stack).
+ apiBaseUrl: process.env.NEXT_PUBLIC_OPENUI_CLOUD_BASE_URL,
+ // Backend mint proxy (POST → { token, expires_at }); openuiCloud caches +
+ // refreshes it and injects x-thesys-frontend-token on every /v1 call.
+ token: "/api/frontend-token",
+ features: { artifact: true },
+});
+
+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();
+
+ return (
+
+ );
+}
diff --git a/packages/openui-cli/src/templates/openui-cloud/src/hooks/use-system-theme.tsx b/packages/openui-cli/src/templates/openui-cloud/src/hooks/use-system-theme.tsx
new file mode 100644
index 000000000..7c110c21d
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/src/hooks/use-system-theme.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { createContext, useContext, useLayoutEffect, useState } from "react";
+
+type ThemeMode = "light" | "dark";
+
+interface ThemeContextType {
+ mode: ThemeMode;
+}
+
+const ThemeContext = createContext(undefined);
+
+function getSystemMode(): ThemeMode {
+ if (typeof window === "undefined") return "light";
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+}
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const [mode, setMode] = useState(getSystemMode);
+
+ useLayoutEffect(() => {
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
+ const handler = (e: MediaQueryListEvent) => setMode(e.matches ? "dark" : "light");
+ mq.addEventListener("change", handler);
+ return () => mq.removeEventListener("change", handler);
+ }, []);
+
+ useLayoutEffect(() => {
+ document.body.setAttribute("data-theme", mode);
+ }, [mode]);
+
+ return {children} ;
+}
+
+export function useTheme(): ThemeMode {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) {
+ throw new Error("useTheme must be used within a ThemeProvider");
+ }
+ return ctx.mode;
+}
diff --git a/packages/openui-cli/src/templates/openui-cloud/src/lib/artifactRenderers/ArtifactComponents.tsx b/packages/openui-cli/src/templates/openui-cloud/src/lib/artifactRenderers/ArtifactComponents.tsx
new file mode 100644
index 000000000..0a49e4d08
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/src/lib/artifactRenderers/ArtifactComponents.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useArtifactStorage, type ArtifactRendererControls } from "@openuidev/react-headless";
+import { Presentation, Report } from "@openuidev/thesys";
+import { useEffect, useRef, useState } from "react";
+import { artifactHeading, parseArtifactResult, type ArtifactProps } from "./parseArtifact";
+
+/**
+ * Inline chat preview. With the program in hand it wraps the SDK viewer's own
+ * chip (preview mode, isOpen pinned false); chip clicks surface through
+ * onOpenChange → controls.open() so the DetailedViewPanel owns the open state.
+ * On a stripped reload the program is fetched by id (same as the full view);
+ * while streaming / before that, a minimal chip shows the name.
+ */
+export function ArtifactPreview({
+ props,
+ controls,
+}: {
+ props: ArtifactProps;
+ controls: ArtifactRendererControls;
+}) {
+ const storage = useArtifactStorage();
+ const [fetched, setFetched] = useState(null);
+ const requestIdRef = useRef(0);
+ useEffect(() => {
+ const requestId = ++requestIdRef.current;
+ if (props.content !== null || props.artifactId === null || storage === null) return;
+ storage
+ .get(props.artifactId)
+ .then((artifact) => {
+ if (requestId !== requestIdRef.current) return;
+ const parsed = parseArtifactResult(artifact.content);
+ if (parsed?.source === "envelope" && parsed.envelope.content !== undefined)
+ setFetched(parsed.envelope.content);
+ else if (parsed?.source === "program") setFetched(parsed.content);
+ })
+ .catch(() => {
+ /* leave the minimal chip in place on fetch failure */
+ });
+ }, [storage, props.content, props.artifactId]);
+
+ // content IS the openui-lang program — feed it straight to the viewer.
+ const dsl = props.content ?? fetched;
+
+ if (dsl !== null) {
+ const Chip = props.kind === "report" ? Report : Presentation;
+ return controls.open()} />;
+ }
+
+ const streaming = props.phase === "streaming";
+ return (
+ controls.open()}
+ disabled={streaming}
+ style={{
+ display: "block",
+ width: "100%",
+ textAlign: "left",
+ border: "1px solid var(--openui-color-border, #ddd)",
+ borderRadius: 8,
+ padding: "12px 14px",
+ margin: "8px 0",
+ cursor: streaming ? "default" : "pointer",
+ background: "transparent",
+ }}
+ >
+ {artifactHeading(props)}
+
+ {props.kind === "report" ? "Report" : "Presentation"}
+ {streaming ? " · generating…" : " · view"}
+
+
+ );
+}
+
+/**
+ * Full artifact view (side panel in-thread; full page in the artifact browser).
+ * Hydrates the program via ArtifactStorage.get(id) when the stored envelope was
+ * stripped to metadata only.
+ */
+export function ArtifactActual({ props }: { props: ArtifactProps }) {
+ const storage = useArtifactStorage();
+ const [fetched, setFetched] = useState(null);
+ const [error, setError] = useState(null);
+ const requestIdRef = useRef(0);
+
+ useEffect(() => {
+ const requestId = ++requestIdRef.current;
+ if (props.content !== null || props.artifactId === null || storage === null) return;
+ storage
+ .get(props.artifactId)
+ .then((artifact) => {
+ if (requestId !== requestIdRef.current) return;
+ const parsed = parseArtifactResult(artifact.content);
+ if (parsed?.source === "envelope" && parsed.envelope.content !== undefined)
+ setFetched(parsed.envelope.content);
+ else if (parsed?.source === "program") setFetched(parsed.content);
+ else setError("Artifact content is empty or unrecognized.");
+ })
+ .catch((e: unknown) => {
+ if (requestId !== requestIdRef.current) return;
+ setError(e instanceof Error ? e.message : String(e));
+ });
+ }, [storage, props.content, props.artifactId]);
+
+ // content IS the openui-lang program — feed it straight to the viewer.
+ const dsl = props.content ?? fetched;
+ const effectiveError =
+ error ??
+ (dsl === null && props.artifactId !== null && storage === null
+ ? "No artifact storage configured."
+ : null);
+
+ if (effectiveError !== null && dsl === null) {
+ return Failed to load artifact: {effectiveError}
;
+ }
+ if (dsl === null) {
+ return (
+
+ {props.phase === "streaming" ? "Generating…" : "Loading…"}
+
+ );
+ }
+ const Full = props.kind === "report" ? Report : Presentation;
+ return ;
+}
diff --git a/packages/openui-cli/src/templates/openui-cloud/src/lib/artifactRenderers/index.tsx b/packages/openui-cli/src/templates/openui-cloud/src/lib/artifactRenderers/index.tsx
new file mode 100644
index 000000000..b3272348d
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/src/lib/artifactRenderers/index.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import {
+ defineArtifactRenderer,
+ type ArtifactCategory,
+ type ArtifactRendererControls,
+} from "@openuidev/react-headless";
+import { ArtifactActual, ArtifactPreview } from "./ArtifactComponents";
+import { artifactParser, type ArtifactProps } from "./parseArtifact";
+
+/**
+ * The backend's artifact tool function names — the toolName routing seam.
+ * 'thesys_get_artifact_description' is intentionally NOT registered (its result
+ * is prose for the model and should keep the default tool chip).
+ */
+export const ARTIFACT_TOOL_NAMES = ["thesys_generate_artifact", "thesys_edit_artifact"] as const;
+
+const shared = {
+ parser: artifactParser,
+ preview: (props: ArtifactProps, controls: ArtifactRendererControls) => (
+
+ ),
+ actual: (props: ArtifactProps) => ,
+};
+
+/**
+ * Owns the toolName route. One tool serves both kinds and the registry is
+ * first-wins per toolName, so this single config takes the tool names and the
+ * shared parser branches on the envelope's type.
+ */
+export const presentationArtifactRenderer = defineArtifactRenderer({
+ type: "presentation",
+ toolName: [...ARTIFACT_TOOL_NAMES],
+ ...shared,
+});
+
+/**
+ * byType registration only (the artifact browser resolves stored reports by
+ * type). toolName: [] registers nothing in the toolName map.
+ */
+export const reportArtifactRenderer = defineArtifactRenderer({
+ type: "report",
+ toolName: [],
+ ...shared,
+});
+
+/** Pass to .
+ * Order matters: presentation first (it owns the tool names). */
+export const artifactRenderers = [presentationArtifactRenderer, reportArtifactRenderer];
+
+/**
+ * One category covering both kinds. Live tool-call registrations are stamped
+ * with the matched config's type (always 'presentation', which owns the tool
+ * names); a single category keeps that invisible in the artifact browser rail.
+ */
+export const artifactCategories: ArtifactCategory[] = [
+ { name: "Artifacts", filter: { type: ["presentation", "report"] } },
+];
diff --git a/packages/openui-cli/src/templates/openui-cloud/src/lib/artifactRenderers/parseArtifact.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/artifactRenderers/parseArtifact.ts
new file mode 100644
index 000000000..fb685deba
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/src/lib/artifactRenderers/parseArtifact.ts
@@ -0,0 +1,268 @@
+/**
+ * Pure parsing for the artifact tool-call carrier (no React imports).
+ *
+ * The carrier is now ONE inline-sentinel string (backend artifact-shared.ts):
+ * ]]>openui:artifact {"artifact_id","type","name"?,"version"?}\n
+ * Same shape live (header first, program appends), at completion, and — header
+ * only, no program — on a stripped reload. So there is a SINGLE parser; the
+ * old dual-shape (streaming {content} + full {artifact_id,…}) is gone, and the
+ * artifact_id is known from the first frame (register immediately).
+ *
+ * `parseArtifactResult` remains for STORAGE content (a bare program fetched via
+ * ArtifactStorage.get) and the bare-program fallback.
+ */
+
+export type ArtifactKind = "presentation" | "report";
+
+export interface ArtifactEnvelope {
+ artifact_id: string;
+ type: ArtifactKind;
+ name?: string;
+ version?: string;
+ /** openui-lang program. Absent on the stripped-reload shape. */
+ content?: string;
+}
+
+export type ParsedArtifactResult =
+ | { source: "envelope"; envelope: ArtifactEnvelope }
+ | { source: "program"; kind: ArtifactKind; content: string };
+
+export interface ArtifactProps {
+ kind: ArtifactKind;
+ /** Storage id; null only for the bare-program fallback / earliest in-flight frames. */
+ artifactId: string | null;
+ name: string | null;
+ version: string | null;
+ /** openui-lang program; null ⇒ streaming-before-program or stripped reload. */
+ content: string | null;
+ phase: "streaming" | "ready";
+}
+
+export interface ArtifactParseResult {
+ props: ArtifactProps;
+ meta: { id: string; version: number; heading: string } | null;
+}
+
+const OPENUI_ARTIFACT_MARKER = "]]>openui:artifact";
+
+const KIND_BY_TYPE: Record = {
+ presentation: "presentation",
+ slides: "presentation",
+ report: "report",
+};
+
+/** Program-root sniff for the bare-string storage fallback. */
+const PROGRAM_ROOT_RE = /^\s*root\s*=\s*(SlideShow|ReportView)\s*\(/m;
+
+const FALLBACK_HEADING: Record = {
+ presentation: "Presentation",
+ report: "Report",
+};
+
+export function artifactHeading(p: Pick): string {
+ return p.name ?? FALLBACK_HEADING[p.kind];
+}
+
+/** Strip a leading ```lang fence (and the trailing ``` once it arrives) so a
+ * partial program feeds the viewers clean. */
+function stripProgramFences(s: string): string {
+ let t = s.trim();
+ if (t.startsWith("```")) {
+ t = t.replace(/^```[^\n]*\n?/, "");
+ const close = t.lastIndexOf("```");
+ if (close !== -1) t = t.slice(0, close);
+ }
+ return t.trim();
+}
+
+export interface ArtifactSentinelHeader {
+ artifact_id: string;
+ type: ArtifactKind;
+ name?: string;
+ version?: string;
+}
+
+/**
+ * Parse the inline-sentinel carrier `]]>openui:artifact \n`.
+ * Returns the validated header + raw program (program may be "" on a stripped
+ * reload). Returns null when `raw` is not an artifact sentinel — the caller then
+ * tries the bare-program fallback.
+ */
+export function parseArtifactSentinel(
+ raw: unknown,
+): { header: ArtifactSentinelHeader; program: string } | null {
+ if (typeof raw !== "string") return null;
+ const prefix = `${OPENUI_ARTIFACT_MARKER} `;
+ if (!raw.startsWith(prefix)) return null;
+ const nl = raw.indexOf("\n");
+ const headerStr = nl === -1 ? raw.slice(prefix.length) : raw.slice(prefix.length, nl);
+ const program = nl === -1 ? "" : raw.slice(nl + 1);
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(headerStr);
+ } catch {
+ return null;
+ }
+ if (typeof parsed !== "object" || parsed === null) return null;
+ const h = parsed as Record;
+ if (typeof h["artifact_id"] !== "string" || h["artifact_id"] === "") return null;
+ const kind = typeof h["type"] === "string" ? KIND_BY_TYPE[h["type"]] : undefined;
+ if (kind === undefined) return null;
+ return {
+ header: {
+ artifact_id: h["artifact_id"],
+ type: kind,
+ ...(typeof h["name"] === "string" && h["name"] !== "" ? { name: h["name"] } : {}),
+ ...(typeof h["version"] === "string" && h["version"] !== "" ? { version: h["version"] } : {}),
+ },
+ program: stripProgramFences(program),
+ };
+}
+
+/**
+ * Classify STORAGE content (or a bare-program fallback). Storage returns the
+ * bare openui-lang program; the legacy JSON envelope branch is retained only
+ * for older stored rows.
+ */
+export function parseArtifactResult(raw: unknown): ParsedArtifactResult | null {
+ if (typeof raw !== "string" || raw.trim() === "") return null;
+ const text = raw.trim();
+
+ if (text.startsWith("{")) {
+ try {
+ const parsed: unknown = JSON.parse(text);
+ if (typeof parsed === "object" && parsed !== null) {
+ const obj = parsed as Record;
+ const kind = typeof obj["type"] === "string" ? KIND_BY_TYPE[obj["type"]] : undefined;
+ if (typeof obj["artifact_id"] === "string" && obj["artifact_id"] !== "" && kind !== undefined) {
+ return {
+ source: "envelope",
+ envelope: {
+ artifact_id: obj["artifact_id"],
+ type: kind,
+ ...(typeof obj["name"] === "string" && obj["name"] !== "" ? { name: obj["name"] } : {}),
+ ...(typeof obj["version"] === "string" && obj["version"] !== ""
+ ? { version: obj["version"] }
+ : {}),
+ ...(typeof obj["content"] === "string" && obj["content"] !== ""
+ ? { content: obj["content"] }
+ : {}),
+ },
+ };
+ }
+ }
+ return null;
+ } catch {
+ /* not JSON — fall through to the program sniff */
+ }
+ }
+
+ const program = stripProgramFences(text);
+ const root = PROGRAM_ROOT_RE.exec(program);
+ if (root) {
+ return { source: "program", kind: root[1] === "ReportView" ? "report" : "presentation", content: program };
+ }
+ return null;
+}
+
+/** Best-effort field extraction from (possibly partial) streamed tool args. */
+export function extractStreamingArgs(args: unknown): {
+ kind: ArtifactKind | null;
+ artifactId: string | null;
+ name: string | null;
+} {
+ const empty = { kind: null, artifactId: null, name: null };
+ if (typeof args !== "string" || args === "") return empty;
+
+ try {
+ const parsed = JSON.parse(args) as Record;
+ return {
+ kind:
+ typeof parsed["artifact_type"] === "string"
+ ? (KIND_BY_TYPE[parsed["artifact_type"]] ?? null)
+ : null,
+ artifactId: typeof parsed["artifact_id"] === "string" ? parsed["artifact_id"] : null,
+ name: typeof parsed["name"] === "string" ? parsed["name"] : null,
+ };
+ } catch {
+ const field = (key: string): string | null =>
+ new RegExp(`"${key}"\\s*:\\s*"([^"\\\\]*)`).exec(args)?.[1] ?? null;
+ const type = field("artifact_type");
+ return {
+ kind: type !== null ? (KIND_BY_TYPE[type] ?? null) : null,
+ artifactId: field("artifact_id"),
+ name: field("name"),
+ };
+ }
+}
+
+/** Wire version string → numeric version (non-numeric/absent ⇒ 1). */
+export function numericVersion(version: string | undefined | null): number {
+ if (version === undefined || version === null || version === "") return 1;
+ const n = Number.parseInt(version, 10);
+ return Number.isFinite(n) && n > 0 ? n : 1;
+}
+
+/** The renderer `parser` — shared by both configs. */
+export function artifactParser(
+ raw: { args: unknown; response: unknown },
+ ctx: { isStreaming: boolean },
+): ArtifactParseResult | null {
+ if (raw.response !== null && raw.response !== undefined) {
+ // ── Inline-sentinel carrier: ONE shape for live stream + final + reload.
+ const sentinel = parseArtifactSentinel(raw.response);
+ if (sentinel !== null) {
+ const { header, program } = sentinel;
+ const hasProgram = program !== "";
+ const props: ArtifactProps = {
+ kind: header.type,
+ artifactId: header.artifact_id,
+ name: header.name ?? null,
+ version: header.version ?? null,
+ // null ⇒ header-only (stripped reload) OR header-before-program: the
+ // view hydrates via ArtifactStorage.get(artifactId).
+ content: hasProgram ? program : null,
+ phase: ctx.isStreaming ? "streaming" : "ready",
+ };
+ // Register from frame 1 — the header always carries a stable artifact_id.
+ return {
+ props,
+ meta: { id: header.artifact_id, version: numericVersion(header.version), heading: artifactHeading(props) },
+ };
+ }
+
+ // ── Bare-program storage fallback (no sentinel): render, never register.
+ const parsed = parseArtifactResult(raw.response);
+ if (parsed?.source === "program") {
+ return {
+ props: {
+ kind: parsed.kind,
+ artifactId: null,
+ name: null,
+ version: null,
+ content: parsed.content,
+ phase: "ready",
+ },
+ meta: null,
+ };
+ }
+ return null;
+ }
+
+ // No result outside streaming = a dropped/foreign result — skip.
+ if (!ctx.isStreaming) return null;
+
+ // In-flight: tool call started, args possibly partial, no result yet.
+ const partial = extractStreamingArgs(raw.args);
+ return {
+ props: {
+ kind: partial.kind ?? "presentation",
+ artifactId: partial.artifactId,
+ name: partial.name,
+ version: null,
+ content: null,
+ phase: "streaming",
+ },
+ meta: null,
+ };
+}
diff --git a/packages/openui-cli/src/templates/openui-cloud/src/lib/env.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/env.ts
new file mode 100644
index 000000000..815c584ee
--- /dev/null
+++ b/packages/openui-cli/src/templates/openui-cloud/src/lib/env.ts
@@ -0,0 +1,12 @@
+// 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;
+}
diff --git a/packages/openui-cli/src/templates/openui-cloud/src/lib/thesys/artifactStorage.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/thesys/artifactStorage.ts
new file mode 100644
index 000000000..81c1a1b5e
--- /dev/null
+++ b/packages/openui-cli/src/templates/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/packages/openui-cli/src/templates/openui-cloud/src/lib/thesys/frontendTokenManager.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/thesys/frontendTokenManager.ts
new file mode 100644
index 000000000..0f2055b54
--- /dev/null
+++ b/packages/openui-cli/src/templates/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/packages/openui-cli/src/templates/openui-cloud/src/lib/thesys/index.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/thesys/index.ts
new file mode 100644
index 000000000..f93d3e38e
--- /dev/null
+++ b/packages/openui-cli/src/templates/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/packages/openui-cli/src/templates/openui-cloud/src/lib/thesys/items.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/thesys/items.ts
new file mode 100644
index 000000000..26c914d10
--- /dev/null
+++ b/packages/openui-cli/src/templates/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/packages/openui-cli/src/templates/openui-cloud/src/lib/thesys/threadStorage.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/thesys/threadStorage.ts
new file mode 100644
index 000000000..a89af1664
--- /dev/null
+++ b/packages/openui-cli/src/templates/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