From 9b431bb20df63e6d6cf21dcb0c9c9c5e731abc22 Mon Sep 17 00:00:00 2001 From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:00:50 +0530 Subject: [PATCH] Add Mastra harness example --- .../harnesses/mastra-harness/.env.example | 6 + examples/harnesses/mastra-harness/.gitignore | 43 ++ examples/harnesses/mastra-harness/README.md | 83 +++ .../mastra-harness/eslint.config.mjs | 11 + .../harnesses/mastra-harness/next.config.ts | 8 + .../harnesses/mastra-harness/package.json | 36 + .../mastra-harness/postcss.config.mjs | 7 + .../mastra-harness/src/app/api/chat/route.ts | 165 +++++ .../mastra-harness/src/app/globals.css | 160 +++++ .../mastra-harness/src/app/layout.tsx | 22 + .../harnesses/mastra-harness/src/app/page.tsx | 232 +++++++ .../src/generated/system-prompt.txt | 223 +++++++ .../src/hooks/use-system-theme.tsx | 41 ++ .../mastra-harness/src/lib/harness-stream.ts | 184 ++++++ .../src/lib/mastra-harness-chat.ts | 114 ++++ .../mastra-harness/src/lib/mastra-harness.ts | 263 ++++++++ .../mastra-harness/src/lib/thread-store.ts | 111 ++++ .../harnesses/mastra-harness/src/library.ts | 4 + .../harnesses/mastra-harness/tsconfig.json | 41 ++ pnpm-lock.yaml | 624 ++++++++++++++---- 20 files changed, 2267 insertions(+), 111 deletions(-) create mode 100644 examples/harnesses/mastra-harness/.env.example create mode 100644 examples/harnesses/mastra-harness/.gitignore create mode 100644 examples/harnesses/mastra-harness/README.md create mode 100644 examples/harnesses/mastra-harness/eslint.config.mjs create mode 100644 examples/harnesses/mastra-harness/next.config.ts create mode 100644 examples/harnesses/mastra-harness/package.json create mode 100644 examples/harnesses/mastra-harness/postcss.config.mjs create mode 100644 examples/harnesses/mastra-harness/src/app/api/chat/route.ts create mode 100644 examples/harnesses/mastra-harness/src/app/globals.css create mode 100644 examples/harnesses/mastra-harness/src/app/layout.tsx create mode 100644 examples/harnesses/mastra-harness/src/app/page.tsx create mode 100644 examples/harnesses/mastra-harness/src/generated/system-prompt.txt create mode 100644 examples/harnesses/mastra-harness/src/hooks/use-system-theme.tsx create mode 100644 examples/harnesses/mastra-harness/src/lib/harness-stream.ts create mode 100644 examples/harnesses/mastra-harness/src/lib/mastra-harness-chat.ts create mode 100644 examples/harnesses/mastra-harness/src/lib/mastra-harness.ts create mode 100644 examples/harnesses/mastra-harness/src/lib/thread-store.ts create mode 100644 examples/harnesses/mastra-harness/src/library.ts create mode 100644 examples/harnesses/mastra-harness/tsconfig.json diff --git a/examples/harnesses/mastra-harness/.env.example b/examples/harnesses/mastra-harness/.env.example new file mode 100644 index 000000000..ed7ee6a4e --- /dev/null +++ b/examples/harnesses/mastra-harness/.env.example @@ -0,0 +1,6 @@ +OPENAI_API_KEY=sk-... +# OPENAI_MODEL=openai/gpt-5.5 +# OPENAI_BASE_URL=https://api.openai.com/v1 +# +# Optional: persists Mastra Harness threads/state to a different LibSQL database. +# MASTRA_HARNESS_DB_URL=file:./.mastra-harness/openui-harness.db diff --git a/examples/harnesses/mastra-harness/.gitignore b/examples/harnesses/mastra-harness/.gitignore new file mode 100644 index 000000000..41a5d1e2f --- /dev/null +++ b/examples/harnesses/mastra-harness/.gitignore @@ -0,0 +1,43 @@ +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# local harness storage +/.mastra-harness/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/harnesses/mastra-harness/README.md b/examples/harnesses/mastra-harness/README.md new file mode 100644 index 000000000..ddc7b5e79 --- /dev/null +++ b/examples/harnesses/mastra-harness/README.md @@ -0,0 +1,83 @@ +# OpenUI + Mastra Harness + +A generative-UI chat application backed by Mastra's new `Harness` API. The app keeps the normal +OpenUI `` chat surface, while the backend runs a persistent Mastra Harness session +with modes, LibSQL-backed threads/state, and tool activity streamed into OpenUI as AG-UI events. + +## How it works + +```text +Browser / OpenUI FullScreen + | localStorage thread list + transcript + | POST /api/chat { threadId, modeId, messages } + v +Next.js route (nodejs runtime) + | threadId -> Mastra resourceId + | getOrCreate Harness Session + | session.sendMessage(latest user turn) + v +Mastra Harness + | modes + storage + safe mock tools + | message/tool events + v +Harness-to-AG-UI adapter + | SSE data: { AG-UI event } + v +OpenUI renderer +``` + +## What this demonstrates + +- Mastra `Harness` from `@mastra/core/harness`, with one shared Harness and one Session per OpenUI + chat thread. +- LibSQL persistence for Harness threads and state, keyed by each OpenUI thread id. +- Harness modes (`Assist` and `Brief`) configured on the same backing agent, with a composer + picker that switches the active Harness mode before the next user turn. +- Safe mock Mastra tools (`get_weather`, `get_stock_price`) surfaced in OpenUI's behind-the-scenes + tool panel. +- A small adapter that maps Harness events (`message_update`, `tool_start`, `tool_input_delta`, + `tool_end`, `error`) to AG-UI SSE events consumed by `agUIAdapter()`. + +## Run locally + +Install monorepo dependencies from the repository root: + +```bash +pnpm install +``` + +Create an env file in this example: + +```bash +cd examples/harnesses/mastra-harness +cp .env.example .env.local +``` + +Set `OPENAI_API_KEY`, then run: + +```bash +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +## Configuration + +| Environment variable | Default | Purpose | +| ---------------------- | -------------------------------------------- | -------------------------------------- | +| `OPENAI_API_KEY` | unset | API key for the configured model | +| `OPENAI_MODEL` | `openai/gpt-5.5` | Mastra model id | +| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI-compatible endpoint | +| `MASTRA_HARNESS_DB_URL` | `file:./.mastra-harness/openui-harness.db` | LibSQL database for Harness state | + +The `dev` and `build` scripts regenerate `src/generated/system-prompt.txt` from `src/library.ts` +before starting Next, so the backend prompt and frontend OpenUI renderer stay aligned. + +## Notes + +- This example uses safe read-only mock tools and grants the Harness `read` category in each + session. It disables `ask_user`, `submit_plan`, and `subagent` because OpenUI's stock chat + surface does not include a Harness approval/resume UI. +- Browser thread metadata and transcripts are stored in `localStorage`; Mastra Harness state is + stored server-side in LibSQL. The OpenUI thread id is used as the Mastra `resourceId`, so a server + restart can reattach to the most recent Harness thread for that resource. diff --git a/examples/harnesses/mastra-harness/eslint.config.mjs b/examples/harnesses/mastra-harness/eslint.config.mjs new file mode 100644 index 000000000..3e54496da --- /dev/null +++ b/examples/harnesses/mastra-harness/eslint.config.mjs @@ -0,0 +1,11 @@ +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; +import { defineConfig, globalIgnores } from "eslint/config"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts", "src/generated/**"]), +]); + +export default eslintConfig; diff --git a/examples/harnesses/mastra-harness/next.config.ts b/examples/harnesses/mastra-harness/next.config.ts new file mode 100644 index 000000000..8f83eea2a --- /dev/null +++ b/examples/harnesses/mastra-harness/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + serverExternalPackages: ["@libsql/client", "@mastra/core", "@mastra/libsql", "libsql"], + turbopack: {}, +}; + +export default nextConfig; diff --git a/examples/harnesses/mastra-harness/package.json b/examples/harnesses/mastra-harness/package.json new file mode 100644 index 000000000..789474704 --- /dev/null +++ b/examples/harnesses/mastra-harness/package.json @@ -0,0 +1,36 @@ +{ + "name": "mastra-harness", + "version": "0.1.0", + "private": true, + "scripts": { + "generate:prompt": "pnpm --filter @openuidev/cli build && node ../../../packages/openui-cli/dist/index.js generate src/library.ts --out src/generated/system-prompt.txt", + "dev": "pnpm generate:prompt && next dev --webpack", + "build": "pnpm generate:prompt && next build --webpack", + "start": "next start", + "lint": "eslint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@mastra/core": "^1.46.0", + "@mastra/libsql": "^1.14.1", + "@openuidev/react-headless": "workspace:*", + "@openuidev/react-lang": "workspace:*", + "@openuidev/react-ui": "workspace:*", + "lucide-react": "^0.562.0", + "next": "16.2.6", + "react": "19.2.3", + "react-dom": "19.2.3", + "zod": "4.4.3" + }, + "devDependencies": { + "@openuidev/cli": "workspace:*", + "@tailwindcss/postcss": "^4", + "@types/node": "catalog:", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/examples/harnesses/mastra-harness/postcss.config.mjs b/examples/harnesses/mastra-harness/postcss.config.mjs new file mode 100644 index 000000000..61e36849c --- /dev/null +++ b/examples/harnesses/mastra-harness/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/examples/harnesses/mastra-harness/src/app/api/chat/route.ts b/examples/harnesses/mastra-harness/src/app/api/chat/route.ts new file mode 100644 index 000000000..bb3957548 --- /dev/null +++ b/examples/harnesses/mastra-harness/src/app/api/chat/route.ts @@ -0,0 +1,165 @@ +import { HarnessAGUIBridge } from "@/lib/harness-stream"; +import { getOrCreateHarnessSession } from "@/lib/mastra-harness"; +import type { AGUIEvent, Message } from "@openuidev/react-headless"; +import type { NextRequest } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const AGUI = { + RUN_ERROR: "RUN_ERROR", + TEXT_MESSAGE_CONTENT: "TEXT_MESSAGE_CONTENT", + TEXT_MESSAGE_END: "TEXT_MESSAGE_END", + TEXT_MESSAGE_START: "TEXT_MESSAGE_START", +} as const; + +interface ChatBody { + modeId?: string; + threadId?: string; + messages?: Message[]; +} + +const VALID_MODE_IDS = new Set(["assist", "brief"]); + +function normalizeModeId(modeId: string | undefined): "assist" | "brief" | undefined { + return VALID_MODE_IDS.has(modeId ?? "") ? (modeId as "assist" | "brief") : undefined; +} + +function messageText(message: Pick): string { + const content = message.content as unknown; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => + part && typeof part === "object" && "text" in part + ? String((part as { text?: unknown }).text ?? "") + : "", + ) + .join("\n"); + } + return ""; +} + +function latestUserText(messages: Message[] | undefined): string { + const user = [...(messages ?? [])].reverse().find((m) => m.role === "user"); + return user ? messageText(user).trim() : ""; +} + +function sse(event: AGUIEvent | "[DONE]"): Uint8Array { + const encoder = new TextEncoder(); + if (event === "[DONE]") return encoder.encode("data: [DONE]\n\n"); + return encoder.encode(`data: ${JSON.stringify(event)}\n\n`); +} + +function textEvent(messageId: string, delta: string): AGUIEvent { + return { type: AGUI.TEXT_MESSAGE_CONTENT, messageId, delta } as AGUIEvent; +} + +function textStreamResponse(content: string): Response { + const messageId = crypto.randomUUID(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + sse({ type: AGUI.TEXT_MESSAGE_START, messageId, role: "assistant" } as AGUIEvent), + ); + controller.enqueue(sse(textEvent(messageId, content))); + controller.enqueue(sse({ type: AGUI.TEXT_MESSAGE_END, messageId } as AGUIEvent)); + controller.enqueue(sse("[DONE]")); + controller.close(); + }, + }); + return new Response(body, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} + +export async function POST(req: NextRequest) { + const body = (await req.json().catch(() => ({}))) as ChatBody; + const conversationId = body.threadId || crypto.randomUUID(); + const modeId = normalizeModeId(body.modeId); + const userText = latestUserText(body.messages); + + if (!userText) { + return textStreamResponse("_No user message was provided._"); + } + + let entry: Awaited>; + try { + entry = await getOrCreateHarnessSession(conversationId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + if (entry.session.run.isRunning()) { + return textStreamResponse("_Still responding to your previous message. Please wait._"); + } + + if (modeId && entry.session.mode.get() !== modeId) { + await entry.session.mode.switch({ modeId }); + } + + const bridge = new HarnessAGUIBridge(); + + const stream = new ReadableStream({ + start(controller) { + let closed = false; + const enqueue = (event: AGUIEvent | "[DONE]") => { + if (closed) return; + try { + controller.enqueue(sse(event)); + } catch { + closed = true; + } + }; + const finish = () => { + if (closed) return; + for (const event of bridge.finish()) enqueue(event); + enqueue("[DONE]"); + closed = true; + try { + controller.close(); + } catch { + // already closed + } + }; + + const unsubscribe = entry.session.subscribe((event) => { + for (const aguiEvent of bridge.consume(event)) enqueue(aguiEvent); + }); + + const onAbort = () => entry.session.abort(); + req.signal.addEventListener("abort", onAbort); + + void (async () => { + try { + await entry.session.sendMessage({ content: userText }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + enqueue({ type: AGUI.RUN_ERROR, message } as AGUIEvent); + } finally { + entry.lastUsed = Date.now(); + req.signal.removeEventListener("abort", onAbort); + unsubscribe(); + finish(); + } + })(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "x-conversation-id": conversationId, + }, + }); +} diff --git a/examples/harnesses/mastra-harness/src/app/globals.css b/examples/harnesses/mastra-harness/src/app/globals.css new file mode 100644 index 000000000..4117c8109 --- /dev/null +++ b/examples/harnesses/mastra-harness/src/app/globals.css @@ -0,0 +1,160 @@ +@import "@openuidev/react-ui/styles/index.css"; + +html, +body { + margin: 0; +} + +.app-shell { + height: 100vh; + overflow: hidden; + position: relative; + width: 100vw; +} + +.mastra-harness-composer .openui-shell-thread-composer__input-wrapper { + overflow: visible; +} + +.mastra-harness-composer .openui-shell-thread-composer__action-bar { + gap: var(--openui-space-s); +} + +.openui-shell-container--mobile .mastra-harness-composer .openui-shell-thread-composer__action-bar { + padding-left: 38px; +} + +.mastra-mode-picker { + align-items: center; + display: inline-flex; + min-width: 0; + position: relative; +} + +.mastra-mode-picker__trigger { + align-items: center; + appearance: none; + background: var(--openui-sunk); + border: 1px solid var(--openui-border-default); + border-radius: var(--openui-radius-s); + color: var(--openui-text-neutral-primary); + cursor: pointer; + display: inline-flex; + font-family: var(--openui-font-label); + gap: var(--openui-space-2xs); + height: 32px; + letter-spacing: 0; + line-height: 1; + padding: 0 var(--openui-space-s); + white-space: nowrap; +} + +.mastra-mode-picker__trigger:hover, +.mastra-mode-picker__trigger[aria-expanded="true"] { + background: var(--openui-foreground); + border-color: var(--openui-border-interactive); +} + +.mastra-mode-picker__trigger:focus-visible { + outline: 2px solid var(--openui-border-interactive-selected); + outline-offset: 2px; +} + +.mastra-mode-picker__eyebrow { + color: var(--openui-text-neutral-secondary); + font-size: var(--openui-font-size-xs); + font-weight: 500; +} + +.mastra-mode-picker__value { + color: var(--openui-text-neutral-primary); + font-size: var(--openui-font-size-xs); + font-weight: 600; +} + +.mastra-mode-picker__chevron { + color: var(--openui-text-neutral-secondary); + transition: transform 120ms ease; +} + +.mastra-mode-picker__trigger[aria-expanded="true"] .mastra-mode-picker__chevron { + transform: rotate(180deg); +} + +.mastra-mode-picker__menu { + background: var(--openui-popover-background); + border: 1px solid var(--openui-border-default); + border-radius: var(--openui-radius-s); + box-shadow: 0 8px 24px var(--openui-elevated-strong); + display: grid; + gap: var(--openui-space-2xs); + font-family: var(--openui-font-body); + inline-size: min(312px, calc(100vw - 32px)); + padding: var(--openui-space-xs); + position: absolute; + bottom: calc(100% + var(--openui-space-xs)); + left: 0; + z-index: 30; +} + +.mastra-mode-picker__menu-label { + color: var(--openui-text-neutral-secondary); + font-family: var(--openui-font-label); + font-size: var(--openui-font-size-xs); + font-weight: 600; + line-height: 1; + padding: var(--openui-space-2xs) var(--openui-space-xs); +} + +.mastra-mode-picker__option { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: var(--openui-radius-xs); + color: var(--openui-text-neutral-primary); + cursor: pointer; + display: flex; + gap: var(--openui-space-s); + justify-content: space-between; + letter-spacing: 0; + min-height: 52px; + padding: var(--openui-space-xs); + text-align: left; + width: 100%; +} + +.mastra-mode-picker__option:hover, +.mastra-mode-picker__option[data-active="true"] { + background: var(--openui-sunk); +} + +.mastra-mode-picker__option:focus-visible { + outline: 2px solid var(--openui-border-interactive-selected); + outline-offset: 2px; +} + +.mastra-mode-picker__option-copy { + display: grid; + gap: var(--openui-space-2xs); + min-width: 0; +} + +.mastra-mode-picker__option-name { + color: var(--openui-text-neutral-primary); + font-family: var(--openui-font-label); + font-size: var(--openui-font-size-xs); + font-weight: 600; + line-height: 1.25; +} + +.mastra-mode-picker__option-description { + color: var(--openui-text-neutral-secondary); + font-size: var(--openui-font-size-xs); + line-height: 1.35; +} + +.mastra-mode-picker__option-check { + color: var(--openui-text-neutral-primary); + flex: 0 0 auto; +} diff --git a/examples/harnesses/mastra-harness/src/app/layout.tsx b/examples/harnesses/mastra-harness/src/app/layout.tsx new file mode 100644 index 000000000..c53909782 --- /dev/null +++ b/examples/harnesses/mastra-harness/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: "Mastra Harness + OpenUI", + description: "Generative UI chat powered by Mastra Harness", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/examples/harnesses/mastra-harness/src/app/page.tsx b/examples/harnesses/mastra-harness/src/app/page.tsx new file mode 100644 index 000000000..b0058570d --- /dev/null +++ b/examples/harnesses/mastra-harness/src/app/page.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useTheme } from "@/hooks/use-system-theme"; +import { + createMastraHarnessChatProps, + type HarnessModeId, +} from "@/lib/mastra-harness-chat"; +import { agUIAdapter } from "@openuidev/react-headless"; +import { FullScreen, IconButton } from "@openuidev/react-ui"; +import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib"; +import { ArrowUp, Check, ChevronDown, Square } from "lucide-react"; +import { useId, useLayoutEffect, useMemo, useRef, useState } from "react"; + +const HARNESS_MODES: Array<{ description: string; id: HarnessModeId; label: string }> = [ + { + description: "Full UI answer with tools, tables, sections, and follow-ups.", + id: "assist", + label: "Assist", + }, + { + description: "Tiny executive brief with key point, risk, and next action.", + id: "brief", + label: "Brief", + }, +]; + +let currentHarnessMode: HarnessModeId = "assist"; + +function getCurrentHarnessMode(): HarnessModeId { + return currentHarnessMode; +} + +function ModePicker({ + mode, + onModeChange, +}: { + mode: HarnessModeId; + onModeChange: (mode: HarnessModeId) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const menuId = useId(); + const selectedMode = HARNESS_MODES.find((option) => option.id === mode) ?? HARNESS_MODES[0]; + + const selectMode = (nextMode: HarnessModeId) => { + onModeChange(nextMode); + setIsOpen(false); + }; + + return ( +
{ + if (!event.currentTarget.contains(event.relatedTarget)) { + setIsOpen(false); + } + }} + > + + {isOpen && ( +
+
Mode
+ {HARNESS_MODES.map((option) => ( + + ))} +
+ )} +
+ ); +} + +function MastraModeComposer({ + onSend, + onCancel, + isRunning, + isLoadingMessages, +}: { + onSend: (message: string) => void; + onCancel: () => void; + isRunning: boolean; + isLoadingMessages: boolean; +}) { + const [textContent, setTextContent] = useState(""); + const [mode, setMode] = useState(currentHarnessMode); + const inputRef = useRef(null); + + const handleModeChange = (nextMode: HarnessModeId) => { + currentHarnessMode = nextMode; + setMode(nextMode); + }; + + const handleSubmit = () => { + const message = textContent.trim(); + if (!message || isRunning || isLoadingMessages) return; + + onSend(message); + setTextContent(""); + }; + + useLayoutEffect(() => { + const input = inputRef.current; + if (!input) return; + + input.style.height = "0px"; + input.style.height = `${Math.max(input.scrollHeight, 24)}px`; + }, [textContent]); + + return ( +
0 || undefined} + onClick={(event) => { + if (!(event.target as HTMLElement).closest("button, a, [role='button']")) { + inputRef.current?.focus(); + } + }} + > +
+