[0];
+type AgentMessage = AgentRunInput["messages"][number];
+
+const agentTools: AgentRunInput["tools"] = [
+ {
+ name: getWeather.id,
+ description: getWeather.description,
+ parameters: getWeather.inputSchema,
+ },
+ {
+ name: getStockPrice.id,
+ description: getStockPrice.description,
+ parameters: getStockPrice.inputSchema,
+ },
+];
+
+function getAgent() {
+ const apiKey = process.env.OPENAI_API_KEY;
+ if (!apiKey) {
+ throw new Error(
+ "OPENAI_API_KEY is not set. Please provide it in your environment variables to run this example.",
+ );
+ }
+
+ const baseAgent = new Agent({
+ id: "openui-agent",
+ name: "OpenUI Agent",
+ instructions: `You are a helpful assistant. Use tools when relevant and help the user with their requests. Always format your responses cleanly.\n\n${systemPromptFile}`,
+ model: {
+ id: (process.env.OPENAI_MODEL as `${string}/${string}`) || "openai/gpt-4o",
+ apiKey: apiKey,
+ url: process.env.OPENAI_BASE_URL || "https://api.openai.com/v1",
+ },
+ tools: { getWeather, getStockPrice },
+ });
+
+ return new MastraAgent({
+ agent: baseAgent,
+ resourceId: "chat-user",
+ });
+}
+
+function toAgentMessage(message: Message): AgentMessage | null {
+ switch (message.role) {
+ case "developer":
+ case "system":
+ return {
+ id: message.id,
+ role: message.role,
+ content: message.content,
+ };
+ case "user":
+ return {
+ id: message.id,
+ role: "user",
+ content:
+ typeof message.content === "string"
+ ? message.content
+ : message.content.find(
+ (content): content is TextInputContent => content.type === "text",
+ )?.text || "",
+ };
+ case "assistant":
+ return {
+ id: message.id,
+ role: "assistant",
+ content: message.content,
+ toolCalls: message.toolCalls,
+ };
+ case "tool":
+ return {
+ id: message.id,
+ role: "tool",
+ content: message.content,
+ toolCallId: message.toolCallId,
+ error: message.error,
+ };
+ default:
+ return null;
+ }
+}
+
+export async function POST(req: NextRequest) {
+ try {
+ const { messages, threadId }: { messages: Message[]; threadId: string } = await req.json();
+
+ const convertedMessages = messages
+ .map(toAgentMessage)
+ .filter((message): message is AgentMessage => message !== null);
+
+ const agent = getAgent();
+ const encoder = new TextEncoder();
+
+ const readable = new ReadableStream({
+ start(controller) {
+ const subscription = agent
+ .run({
+ messages: convertedMessages,
+ threadId,
+ runId: crypto.randomUUID(),
+ tools: agentTools,
+ context: [],
+ })
+ .subscribe({
+ next: (event) => {
+ if (
+ (event.type === EventType.TEXT_MESSAGE_CHUNK ||
+ event.type === EventType.TEXT_MESSAGE_CONTENT) &&
+ event.delta
+ ) {
+ const translatedEvent = {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: event.messageId || "current-message",
+ delta: event.delta,
+ };
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(translatedEvent)}\n\n`));
+ } else if (event.type === EventType.RUN_ERROR) {
+ controller.enqueue(
+ encoder.encode(
+ `data: ${JSON.stringify({
+ type: EventType.RUN_ERROR,
+ message: event.message || "An error occurred during the agent run",
+ })}\n\n`,
+ ),
+ );
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
+ }
+ },
+ complete: () => {
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
+ controller.close();
+ },
+ error: (error: unknown) => {
+ const message =
+ error instanceof Error ? error.message : "Unknown Mastra stream error";
+ console.error("Mastra stream error:", error);
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: message })}\n\n`));
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
+ controller.close();
+ },
+ });
+
+ req.signal.addEventListener("abort", () => {
+ subscription.unsubscribe();
+ });
+ },
+ });
+
+ return new Response(readable, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ },
+ });
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : "Unknown route error";
+ console.error("Route error:", error);
+ return new Response(JSON.stringify({ error: message }), {
+ status: 500,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+}
diff --git a/examples/mastra-chat/src/app/globals.css b/examples/mastra-chat/src/app/globals.css
new file mode 100644
index 000000000..f1d8c73cd
--- /dev/null
+++ b/examples/mastra-chat/src/app/globals.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/examples/mastra-chat/src/app/layout.tsx b/examples/mastra-chat/src/app/layout.tsx
new file mode 100644
index 000000000..7a82406ab
--- /dev/null
+++ b/examples/mastra-chat/src/app/layout.tsx
@@ -0,0 +1,22 @@
+import { ThemeProvider } from "@/hooks/use-system-theme";
+import type { Metadata } from "next";
+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/mastra-chat/src/app/page.tsx b/examples/mastra-chat/src/app/page.tsx
new file mode 100644
index 000000000..750010bc7
--- /dev/null
+++ b/examples/mastra-chat/src/app/page.tsx
@@ -0,0 +1,49 @@
+"use client";
+import "@openuidev/react-ui/components.css";
+
+import { useTheme } from "@/hooks/use-system-theme";
+import { agUIAdapter } from "@openuidev/react-headless";
+import { FullScreen } from "@openuidev/react-ui";
+import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
+
+export default function Page() {
+ const mode = useTheme();
+
+ return (
+
+ {
+ return fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ messages, threadId }),
+ signal: abortController.signal,
+ });
+ }}
+ streamProtocol={agUIAdapter()}
+ componentLibrary={openuiChatLibrary}
+ agentName="OpenUI Chat"
+ theme={{ mode }}
+ conversationStarters={{
+ variant: "short",
+ options: [
+ {
+ displayText: "Weather in Tokyo",
+ prompt: "What's the weather like in Tokyo right now?",
+ },
+ { displayText: "AAPL stock price", prompt: "What's the current Apple stock price?" },
+ {
+ displayText: "Contact form",
+ prompt: "Build me a contact form with name, email, topic, and message fields.",
+ },
+ {
+ displayText: "Data table",
+ prompt:
+ "Show me a table of the top 5 programming languages by popularity with year created.",
+ },
+ ],
+ }}
+ />
+
+ );
+}
diff --git a/examples/mastra-chat/src/generated/system-prompt.txt b/examples/mastra-chat/src/generated/system-prompt.txt
new file mode 100644
index 000000000..9a444471c
--- /dev/null
+++ b/examples/mastra-chat/src/generated/system-prompt.txt
@@ -0,0 +1,202 @@
+You are an AI assistant that responds using openui-lang, a declarative UI language. Your ENTIRE response must be valid openui-lang code — no markdown, no explanations, just openui-lang.
+
+## Syntax Rules
+
+1. Each statement is on its own line: `identifier = Expression`
+2. `root` is the entry point — every program must define `root = Card(...)`
+3. Expressions are: strings ("..."), numbers, booleans (true/false), arrays ([...]), objects ({...}), or component calls TypeName(arg1, arg2, ...)
+4. Use references for readability: define `name = ...` on one line, then use `name` later
+5. EVERY variable (except root) MUST be referenced by at least one other variable. Unreferenced variables are silently dropped and will NOT render. Always include defined variables in their parent's children/items array.
+6. Arguments are POSITIONAL (order matters, not names)
+7. Optional arguments can be omitted from the end
+8. No operators, no logic, no variables — only declarations
+9. Strings use double quotes with backslash escaping
+
+## Component Signatures
+
+Arguments marked with ? are optional. Sub-components can be inline or referenced; prefer references for better streaming.
+The `action` prop type accepts: ContinueConversation (sends message to LLM), OpenUrl (navigates to URL), or Custom (app-defined).
+
+### Content
+CardHeader(title?: string, subtitle?: string) — Header with optional title and subtitle
+TextContent(text: string, size?: "small" | "default" | "large" | "small-heavy" | "large-heavy") — Text block. Supports markdown. Optional size: "small" | "default" | "large" | "small-heavy" | "large-heavy".
+MarkDownRenderer(textMarkdown: string, variant?: "clear" | "card" | "sunk") — Renders markdown text with optional container variant
+Callout(variant: "info" | "warning" | "error" | "success" | "neutral", title: string, description: string) — Callout banner with variant, title, and description
+TextCallout(variant?: "neutral" | "info" | "warning" | "success" | "danger", title?: string, description?: string) — Text callout with variant, title, and description
+Image(alt: string, src?: string) — Image with alt text and optional URL
+ImageBlock(src: string, alt?: string) — Image block with loading state
+ImageGallery(images: {src: string, alt?: string, details?: string}[]) — Gallery grid of images with modal preview
+CodeBlock(language: string, codeString: string) — Syntax-highlighted code block
+Separator(orientation?: "horizontal" | "vertical", decorative?: boolean) — Visual divider between content sections
+
+### Tables
+Table(columns: Col[], rows: (string | number | boolean)[][]) — Data table
+Col(label: string, type?: "string" | "number" | "action") — Column definition
+
+### Charts (2D)
+BarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Vertical bars; use for comparing values across categories with one or more series
+LineChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Lines over categories; use for trends and continuous data over time
+AreaChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Filled area under lines; use for cumulative totals or volume trends over time
+RadarChart(labels: string[], series: Series[]) — Spider/web chart; use for comparing multiple variables across one or more entities
+HorizontalBarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Horizontal bars; prefer when category labels are long or for ranked lists
+Series(category: string, values: number[]) — One data series
+
+### Charts (1D)
+PieChart(slices: Slice[], variant?: "pie" | "donut") — Circular slices showing part-to-whole proportions; supports pie and donut variants
+RadialChart(slices: Slice[]) — Radial bars showing proportional distribution across named segments
+SingleStackedBarChart(slices: Slice[]) — Single horizontal stacked bar; use for showing part-to-whole proportions in one row
+Slice(category: string, value: number) — One slice with label and numeric value
+
+### Charts (Scatter)
+ScatterChart(datasets: ScatterSeries[], xLabel?: string, yLabel?: string) — X/Y scatter plot; use for correlations, distributions, and clustering
+ScatterSeries(name: string, points: Point[]) — Named dataset
+Point(x: number, y: number, z?: number) — Data point with numeric coordinates
+
+### Forms
+Form(name: string, buttons: Buttons, fields) — Form container with fields and explicit action buttons
+FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup, hint?: string) — Field with label, input component, and optional hint text
+Label(text: string) — Text label
+Input(name: string, placeholder?: string, type?: "text" | "email" | "password" | "number" | "url", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string})
+TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string})
+Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string})
+SelectItem(value: string, label: string) — Option for Select
+DatePicker(name: string, mode: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string})
+Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], label?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) — Numeric slider input; supports continuous and discrete (stepped) variants
+CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string})
+CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean)
+RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string})
+RadioItem(label: string, description: string, value: string)
+SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk") — Group of switch toggles
+SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean) — Individual switch toggle
+- Define EACH FormControl as its own reference — do NOT inline all controls in one array.
+- NEVER nest Form inside Form.
+- Form requires explicit buttons. Always pass a Buttons(...) reference as the third Form argument.
+- rules is an optional object: { required: true, email: true, min: 8, maxLength: 100 }
+- The renderer shows error messages automatically — do NOT generate error text in the UI
+
+### Buttons
+Button(label: string, action?: {type: "open_url", url: string} | {type: "continue_conversation", context?: string} | {type: string, params?}, variant?: "primary" | "secondary" | "tertiary", type?: "normal" | "destructive", size?: "extra-small" | "small" | "medium" | "large") — Clickable button
+Buttons(buttons: Button[], direction?: "row" | "column") — Group of Button components. direction: "row" (default) | "column".
+
+### Lists & Follow-ups
+ListBlock(items: ListItem[], variant?: "number" | "image") — A list of items with number or image indicators. Each item can optionally have an action.
+ListItem(title: string, subtitle?: string, image?: {src: string, alt: string}, actionLabel?: string, action?: {type: "open_url", url: string} | {type: "continue_conversation", context?: string} | {type: string, params?}) — Item in a ListBlock — displays a title with an optional subtitle and image. When action is provided, the item becomes clickable.
+FollowUpBlock(items: FollowUpItem[]) — List of clickable follow-up suggestions placed at the end of a response
+FollowUpItem(text: string) — Clickable follow-up suggestion — when clicked, sends text as user message
+- Use ListBlock with ListItem references for numbered, clickable lists.
+- Use FollowUpBlock with FollowUpItem references at the end of a response to suggest next actions.
+- Clicking a ListItem or FollowUpItem sends its text to the LLM as a user message.
+- Example: list = ListBlock([item1, item2]) item1 = ListItem("Option A", "Details about A")
+
+### Sections
+SectionBlock(sections: SectionItem[], isFoldable?: boolean) — Collapsible accordion sections. Auto-opens sections as they stream in. Use SectionItem for each section.
+SectionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | ListBlock | FollowUpBlock)[]) — Section with a label and collapsible content — used inside SectionBlock
+- SectionBlock renders collapsible accordion sections that auto-open as they stream.
+- Each section needs a unique `value` id, a `trigger` label, and a `content` array.
+- Example: sections = SectionBlock([s1, s2]) s1 = SectionItem("intro", "Introduction", [content1])
+- Set isFoldable=false to render sections as flat headers instead of accordion.
+
+### Layout
+Tabs(items: TabItem[]) — Tabbed container
+TabItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is tab label, content is array of components
+Accordion(items: AccordionItem[]) — Collapsible sections
+AccordionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is section title
+Steps(items: StepsItem[]) — Step-by-step guide
+StepsItem(title: string, details: string) — title and details text for one step
+Carousel(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[][], variant?: "card" | "sunk") — Horizontal scrollable carousel
+- Use Tabs to present alternative views — each TabItem has a value id, trigger label, and content array.
+- Carousel takes an array of slides, where each slide is an array of content: carousel = Carousel([[t1, img1], [t2, img2]])
+- IMPORTANT: Every slide in a Carousel must have the same structure — same component types in the same order.
+- For image carousels use: [[title, image, description, tags], ...] — every slide must follow this exact pattern.
+- Use real, publicly accessible image URLs (e.g. https://picsum.photos/seed/KEYWORD/800/500). Never hallucinate image URLs.
+
+### Data Display
+TagBlock(tags: string[]) — tags is an array of strings
+Tag(text: string, icon?: string, size?: "sm" | "md" | "lg", variant?: "neutral" | "info" | "success" | "warning" | "danger") — Styled tag/badge with optional icon and variant
+
+### Ungrouped
+Card(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | ListBlock | FollowUpBlock | SectionBlock | Tabs | Carousel)[]) — Vertical container for all content in a chat response. Children stack top to bottom automatically.
+
+## Hoisting & Streaming (CRITICAL)
+
+openui-lang supports hoisting: a reference can be used BEFORE it is defined. The parser resolves all references after the full input is parsed.
+
+During streaming, the output is re-parsed on every chunk. Undefined references are temporarily unresolved and appear once their definitions stream in. This creates a progressive top-down reveal — structure first, then data fills in.
+
+**Recommended statement order for optimal streaming:**
+1. `root = Card(...)` — UI shell appears immediately
+2. Component definitions — fill in as they stream
+3. Data values — leaf content last
+
+Always write the root = Card(...) statement first so the UI shell appears immediately, even before child data has streamed in.
+
+## Examples
+
+Example 1 — Table with follow-ups:
+root = Card([title, tbl, followUps])
+title = TextContent("Top Languages", "large-heavy")
+tbl = Table(cols, rows)
+cols = [Col("Language", "string"), Col("Users (M)", "number"), Col("Year", "number")]
+rows = [["Python", 15.7, 1991], ["JavaScript", 14.2, 1995], ["Java", 12.1, 1995]]
+followUps = FollowUpBlock([fu1, fu2])
+fu1 = FollowUpItem("Tell me more about Python")
+fu2 = FollowUpItem("Show me a JavaScript comparison")
+
+Example 2 — Clickable list:
+root = Card([title, list])
+title = TextContent("Choose a topic", "large-heavy")
+list = ListBlock([item1, item2, item3])
+item1 = ListItem("Getting started", "New to the platform? Start here.")
+item2 = ListItem("Advanced features", "Deep dives into powerful capabilities.")
+item3 = ListItem("Troubleshooting", "Common issues and how to fix them.")
+
+Example 3 — Image carousel with consistent slides + follow-ups:
+root = Card([header, carousel, followups])
+header = CardHeader("Featured Destinations", "Discover highlights and best time to visit")
+carousel = Carousel([[t1, img1, d1, tags1], [t2, img2, d2, tags2], [t3, img3, d3, tags3]], "card")
+t1 = TextContent("Paris, France", "large-heavy")
+img1 = ImageBlock("https://picsum.photos/seed/paris/800/500", "Eiffel Tower at night")
+d1 = TextContent("City of light — best Apr–Jun and Sep–Oct.", "default")
+tags1 = TagBlock(["Landmark", "City Break", "Culture"])
+t2 = TextContent("Kyoto, Japan", "large-heavy")
+img2 = ImageBlock("https://picsum.photos/seed/kyoto/800/500", "Bamboo grove in Arashiyama")
+d2 = TextContent("Temples and bamboo groves — best Mar–Apr and Nov.", "default")
+tags2 = TagBlock(["Temples", "Autumn", "Culture"])
+t3 = TextContent("Machu Picchu, Peru", "large-heavy")
+img3 = ImageBlock("https://picsum.photos/seed/machupicchu/800/500", "Inca citadel in the clouds")
+d3 = TextContent("High-altitude Inca citadel — best May–Sep.", "default")
+tags3 = TagBlock(["Andes", "Hike", "UNESCO"])
+followups = FollowUpBlock([fu1, fu2])
+fu1 = FollowUpItem("Show me only beach destinations")
+fu2 = FollowUpItem("Turn this into a comparison table")
+
+Example 4 — Form with validation:
+root = Card([title, form])
+title = TextContent("Contact Us", "large-heavy")
+form = Form("contact", btns, [nameField, emailField, msgField])
+nameField = FormControl("Name", Input("name", "Your name", "text", { required: true, minLength: 2 }))
+emailField = FormControl("Email", Input("email", "you@example.com", "email", { required: true, email: true }))
+msgField = FormControl("Message", TextArea("message", "Tell us more...", 4, { required: true, minLength: 10 }))
+btns = Buttons([Button("Submit", { type: "continue_conversation" }, "primary")])
+
+## Important Rules
+- ALWAYS start with root = Card(...)
+- Write statements in TOP-DOWN order: root → components → data (leverages hoisting for progressive streaming)
+- Each statement on its own line
+- No trailing text or explanations — output ONLY openui-lang code
+- 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.)
+- NEVER define a variable without referencing it from the tree. Every variable must be reachable from root, otherwise it will not render.
+
+- Every response is a single Card(children) — children stack vertically automatically. No layout params are needed on Card.
+- Card is the only layout container. Do NOT use Stack. Use Tabs to switch between sections, Carousel for horizontal scroll.
+- Use FollowUpBlock at the END of a Card to suggest what the user can do or ask next.
+- Use ListBlock when presenting a set of options or steps the user can click to select.
+- Use SectionBlock to group long responses into collapsible sections — good for reports, FAQs, and structured content.
+- Use SectionItem inside SectionBlock: each item needs a unique value id, a trigger (header label), and a content array.
+- Carousel takes an array of slides, where each slide is an array of content: carousel = Carousel([[t1, img1], [t2, img2]])
+- IMPORTANT: Every slide in a Carousel must use the same component structure in the same order — e.g. all slides: [title, image, description, tags].
+- For image carousels, always use real accessible URLs like https://picsum.photos/seed/KEYWORD/800/500. Never hallucinate or invent image URLs.
+- For forms, define one FormControl reference per field so controls can stream progressively.
+- For forms, always provide the second Form argument with Buttons(...) actions: Form(name, buttons, fields).
+- Never nest Form inside Form.
diff --git a/examples/mastra-chat/src/hooks/use-system-theme.tsx b/examples/mastra-chat/src/hooks/use-system-theme.tsx
new file mode 100644
index 000000000..7c110c21d
--- /dev/null
+++ b/examples/mastra-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/mastra-chat/src/library.ts b/examples/mastra-chat/src/library.ts
new file mode 100644
index 000000000..316f65a7d
--- /dev/null
+++ b/examples/mastra-chat/src/library.ts
@@ -0,0 +1,4 @@
+export {
+ openuiChatLibrary as library,
+ openuiChatPromptOptions as promptOptions,
+} from "@openuidev/react-ui/genui-lib";
diff --git a/examples/mastra-chat/tsconfig.json b/examples/mastra-chat/tsconfig.json
new file mode 100644
index 000000000..cf9c65d3e
--- /dev/null
+++ b/examples/mastra-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/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts
index c37984d7c..bd291cd78 100644
--- a/packages/react-headless/src/index.ts
+++ b/packages/react-headless/src/index.ts
@@ -8,11 +8,12 @@ export { ArtifactContext, useArtifactStore } from "./store/ArtifactContext";
export { ChatProvider } from "./store/ChatProvider";
export {
agUIAdapter,
+ mastraAdapter,
openAIAdapter,
openAIReadableStreamAdapter,
openAIResponsesAdapter,
} from "./stream/adapters";
-export { openAIConversationMessageFormat, openAIMessageFormat } from "./stream/formats";
+export { mastraMessageFormat, openAIConversationMessageFormat, openAIMessageFormat } from "./stream/formats";
export { processStreamedMessage } from "./stream/processStreamedMessage";
export type { ArtifactActions, ArtifactState } from "./store/artifactTypes";
diff --git a/packages/react-headless/src/stream/adapters/index.ts b/packages/react-headless/src/stream/adapters/index.ts
index a3e2e4d74..9afe7612c 100644
--- a/packages/react-headless/src/stream/adapters/index.ts
+++ b/packages/react-headless/src/stream/adapters/index.ts
@@ -1,4 +1,5 @@
export * from "./ag-ui";
+export * from "./mastra";
export * from "./openai-completions";
export * from "./openai-readable-stream";
export * from "./openai-responses";
diff --git a/packages/react-headless/src/stream/adapters/mastra.ts b/packages/react-headless/src/stream/adapters/mastra.ts
new file mode 100644
index 000000000..d43d94350
--- /dev/null
+++ b/packages/react-headless/src/stream/adapters/mastra.ts
@@ -0,0 +1,86 @@
+import { AGUIEvent, EventType, StreamProtocolAdapter } from "../../types";
+
+export const mastraAdapter = (): StreamProtocolAdapter => ({
+ async *parse(response: Response): AsyncIterable {
+ const reader = response.body?.getReader();
+ if (!reader) throw new Error("No response body");
+
+ const decoder = new TextDecoder();
+ const messageId = crypto.randomUUID();
+
+ yield {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId,
+ role: "assistant",
+ };
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = chunk.split("\n");
+
+ for (const line of lines) {
+ if (!line.startsWith("data: ")) continue;
+ const data = line.slice(6).trim();
+ if (!data || data === "[DONE]") {
+ if (data === "[DONE]") {
+ yield { type: EventType.TEXT_MESSAGE_END, messageId };
+ }
+ continue;
+ }
+
+ try {
+ const event = JSON.parse(data);
+
+ if (event.type === "text-delta" || event.textDelta) {
+ yield {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId,
+ delta: event.textDelta || event.text || "",
+ };
+ } else if (event.type === "tool-call") {
+ yield {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: event.toolCallId,
+ toolCallName: event.toolName,
+ };
+ if (event.argsTextDelta || event.args) {
+ const deltaArgs = event.argsTextDelta || (typeof event.args === "string" ? event.args : JSON.stringify(event.args));
+ yield {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId: event.toolCallId,
+ delta: deltaArgs,
+ };
+ yield {
+ type: EventType.TOOL_CALL_END,
+ toolCallId: event.toolCallId,
+ };
+ }
+ } else if (event.type === "tool-call-delta") {
+ yield {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId: event.toolCallId,
+ delta: event.argsTextDelta,
+ };
+ } else if (event.type === "finish") {
+ yield { type: EventType.TEXT_MESSAGE_END, messageId };
+ } else if (typeof event === "object" && typeof event.text === "string") {
+ yield {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId,
+ delta: event.text,
+ };
+ }
+ } catch (e) {
+ yield {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId,
+ delta: data,
+ };
+ }
+ }
+ }
+ },
+});
diff --git a/packages/react-headless/src/stream/formats/index.ts b/packages/react-headless/src/stream/formats/index.ts
index ed4cdd377..dfac42149 100644
--- a/packages/react-headless/src/stream/formats/index.ts
+++ b/packages/react-headless/src/stream/formats/index.ts
@@ -1,2 +1,3 @@
+export * from "./mastra";
export * from "./openai-conversation-message-format";
export * from "./openai-message-format";
diff --git a/packages/react-headless/src/stream/formats/mastra.ts b/packages/react-headless/src/stream/formats/mastra.ts
new file mode 100644
index 000000000..af4097c33
--- /dev/null
+++ b/packages/react-headless/src/stream/formats/mastra.ts
@@ -0,0 +1,24 @@
+import type { Message } from "../../types/message";
+import type { MessageFormat } from "../../types/messageFormat";
+
+export const mastraMessageFormat: MessageFormat = {
+ toApi: (messages: Message[]) => {
+ return messages.map((m) => {
+ let text = "";
+ if (typeof m.content === "string") {
+ text = m.content;
+ } else if (Array.isArray(m.content)) {
+ const textContent = m.content.find((c) => c.type === "text");
+ text = textContent?.text ?? "";
+ }
+
+ return {
+ role: m.role,
+ content: text,
+ };
+ });
+ },
+ fromApi: (data: unknown) => {
+ return Array.isArray(data) ? (data as Message[]) : [];
+ },
+};