Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions examples/harnesses/mastra-harness/.env.example
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions examples/harnesses/mastra-harness/.gitignore
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions examples/harnesses/mastra-harness/README.md
Original file line number Diff line number Diff line change
@@ -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 `<FullScreen />` 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.
11 changes: 11 additions & 0 deletions examples/harnesses/mastra-harness/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions examples/harnesses/mastra-harness/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
serverExternalPackages: ["@libsql/client", "@mastra/core", "@mastra/libsql", "libsql"],
turbopack: {},
};

export default nextConfig;
36 changes: 36 additions & 0 deletions examples/harnesses/mastra-harness/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
7 changes: 7 additions & 0 deletions examples/harnesses/mastra-harness/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};

export default config;
165 changes: 165 additions & 0 deletions examples/harnesses/mastra-harness/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -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<Message, "content">): 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<Uint8Array>({
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<ReturnType<typeof getOrCreateHarnessSession>>;
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<Uint8Array>({
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,
},
});
}
Loading
Loading