From 8bcf3ac6972a1377b69e3adeeb1fa13763042606 Mon Sep 17 00:00:00 2001
From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com>
Date: Mon, 24 Aug 2026 17:19:07 +0530
Subject: [PATCH 1/2] feat: add Agno AgentOS adapter package
---
README.md | 5 +
packages/agno/README.md | 101 +++++++
packages/agno/eslint.config.cjs | 58 ++++
packages/agno/package.json | 71 +++++
packages/agno/src/__tests__/adapter.test.ts | 85 ++++++
packages/agno/src/__tests__/llm.test.ts | 54 ++++
packages/agno/src/__tests__/storage.test.ts | 176 ++++++++++++
packages/agno/src/adapter.ts | 86 ++++++
packages/agno/src/index.ts | 5 +
packages/agno/src/llm.ts | 51 ++++
packages/agno/src/storage.ts | 301 ++++++++++++++++++++
packages/agno/tsconfig.json | 17 ++
packages/agno/tsconfig.test.json | 9 +
packages/agno/tsdown.config.ts | 14 +
packages/agno/vitest.config.ts | 14 +
pnpm-lock.yaml | 12 +
16 files changed, 1059 insertions(+)
create mode 100644 packages/agno/README.md
create mode 100644 packages/agno/eslint.config.cjs
create mode 100644 packages/agno/package.json
create mode 100644 packages/agno/src/__tests__/adapter.test.ts
create mode 100644 packages/agno/src/__tests__/llm.test.ts
create mode 100644 packages/agno/src/__tests__/storage.test.ts
create mode 100644 packages/agno/src/adapter.ts
create mode 100644 packages/agno/src/index.ts
create mode 100644 packages/agno/src/llm.ts
create mode 100644 packages/agno/src/storage.ts
create mode 100644 packages/agno/tsconfig.json
create mode 100644 packages/agno/tsconfig.test.json
create mode 100644 packages/agno/tsdown.config.ts
create mode 100644 packages/agno/vitest.config.ts
diff --git a/README.md b/README.md
index 0a5bcd439..6df7daac6 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,7 @@ Try it yourself in the [Playground](https://www.openui.com/playground): generate
| :--------------------------------------------------------------------------------------------------------- | :----------------------------------------------- | :----------------------------------------------------------------------------------------------------------- |
| [`@openuidev/lang-core`](./packages/lang-core) | Framework-agnostic parsing and prompt generation | Core parser, prompt-generation, runtime-evaluation, and type layer with no React, Vue, or Svelte dependency |
| [`@openuidev/langchain`](./packages/langchain) | LangChain and LangGraph agents | Agent transformer and server helpers that stream OpenUI through AG-UI |
+| [`@openuidev/agno`](./packages/agno) | Agno AgentOS agents and teams | AG-UI streaming and AgentOS session adapters for OpenUI chat interfaces |
| [`@openuidev/react-lang`](./packages/react-lang) | React rendering runtimes | Define component libraries, generate prompts, and render streamed OpenUI Lang in React |
| [`@openuidev/react-headless`](./packages/react-headless) | Bring-your-own React chat UI | Headless chat state, streaming adapters, and message format converters |
| [`@openuidev/react-ui`](./packages/react-ui) | Fastest path to a full React chat experience | Prebuilt chat layouts, standalone UI primitives, and two built-in component libraries |
@@ -117,6 +118,9 @@ npm install @openuidev/lang-core
# LangChain/LangGraph agent and server integration
npm install @openuidev/langchain @langchain/langgraph
+# Agno AgentOS integration
+npm install @openuidev/agno @openuidev/react-ui
+
# Vue or Svelte runtime
npm install @openuidev/vue-lang
npm install @openuidev/svelte-lang
@@ -163,6 +167,7 @@ openui/
│ ├── react-email/ # React Email component library for generated emails
│ ├── lang-core/ # Framework-agnostic parser, prompt, and runtime layer
│ ├── langchain/ # LangChain/LangGraph streaming integration
+│ ├── agno/ # Agno AgentOS streaming and session integration
│ ├── vue-lang/ # Vue runtime bindings for OpenUI Lang
│ ├── svelte-lang/ # Svelte runtime bindings for OpenUI Lang
│ ├── browser-bundle/ # Script-tag bundle for CDN / iframe / no-build embeds
diff --git a/packages/agno/README.md b/packages/agno/README.md
new file mode 100644
index 000000000..fb68afd65
--- /dev/null
+++ b/packages/agno/README.md
@@ -0,0 +1,101 @@
+# `@openuidev/agno`
+
+Connect an Agno AgentOS to OpenUI without copying transport or persistence glue.
+
+The integration is intentionally complementary:
+
+- **AgentOS handles everything behind the UI:** agents, teams, models, tools,
+ memory, knowledge, sessions, authorization, execution, and deployment.
+- **OpenUI handles the UI:** component instructions, streamed OpenUI Lang,
+ rendering, interactions, forms, charts, theming, and the chat surface.
+- **AG-UI is the boundary** between the two systems.
+
+## Install
+
+```bash
+pnpm add @openuidev/agno @openuidev/react-ui
+```
+
+Import OpenUI styles once:
+
+```css
+@import "@openuidev/react-ui/layered/styles/index.css";
+```
+
+## Connect AgentInterface to AgentOS
+
+```tsx
+import { createAgnoLLM, agnoStorage } from "@openuidev/agno";
+import { AgentInterface } from "@openuidev/react-ui";
+
+const llm = createAgnoLLM({
+ url: "http://localhost:7777/agui",
+ forwardedProps: { user_id: "demo-user" },
+});
+
+const storage = agnoStorage({
+ baseUrl: "http://localhost:7777",
+ entityType: "agent",
+ entityId: "openui-assistant",
+ userId: "demo-user",
+});
+
+export function Chat() {
+ return ;
+}
+```
+
+For an authenticated AgentOS, pass a scoped bearer token through `token` and
+omit `userId`/`forwardedProps.user_id`; AgentOS derives identity from the token.
+
+## What the package owns
+
+- `createAgnoLLM()` adds AgentOS's AG-UI extension containers and configures
+ the Agno-aware stream adapter.
+- `agnoAGUIAdapter()` removes non-chat lifecycle/state events and Agno's empty
+ tool-parent text envelope while retaining streamed text, tools, and errors.
+- `agnoStorage()` maps OpenUI threads to AgentOS `/sessions` APIs and reloads
+ messages from AgentOS `chat_history`.
+- The mapping is deliberately 1:1: the OpenUI `thread.id` is the AgentOS
+ `session_id`, so chat, persistence, inspection, and operational tooling all
+ address the same conversation without a translation table.
+- `agnoHistoryToMessages()` exposes the tolerant history conversion separately
+ for custom storage implementations.
+
+The package does not run an agent, proxy model keys, or create a second source
+of conversation truth.
+
+## AgentOS backend
+
+The backend remains normal Agno Python code. Generate the OpenUI component
+prompt from the frontend library, then include it in the agent instructions:
+
+```python
+from agno.agent import Agent
+from agno.db.sqlite import SqliteDb
+from agno.models.openai import OpenAIResponses
+from agno.os import AgentOS
+from agno.os.interfaces.agui import AGUI
+
+agent = Agent(
+ id="openui-assistant",
+ model=OpenAIResponses(id="gpt-5.5"),
+ db=SqliteDb(id="openui", db_file="tmp/openui.db"),
+ instructions=[openui_system_prompt],
+ add_history_to_context=True,
+)
+
+agent_os = AgentOS(agents=[agent], interfaces=[AGUI(agent=agent)])
+app = agent_os.get_app()
+```
+
+The local `examples/agno-chat` workspace contains a runnable client, a real
+AgentOS server, and a deterministic no-key development harness.
+
+## Current scope
+
+This first local implementation supports streamed OpenUI responses, backend
+tool timelines, authentication headers, agents/teams, and AgentOS-backed
+conversation persistence. Native rendering and resumption of Agno HITL/client
+tools is the next integration layer rather than something this package claims
+to support already.
diff --git a/packages/agno/eslint.config.cjs b/packages/agno/eslint.config.cjs
new file mode 100644
index 000000000..87cd87c88
--- /dev/null
+++ b/packages/agno/eslint.config.cjs
@@ -0,0 +1,58 @@
+const tseslint = require("@typescript-eslint/eslint-plugin");
+const typescript = require("@typescript-eslint/parser");
+const prettier = require("eslint-config-prettier");
+const unusedImports = require("eslint-plugin-unused-imports");
+const eslintPluginPrettier = require("eslint-plugin-prettier");
+
+module.exports = [
+ {
+ files: ["**/__tests__/**/*.{ts,tsx}", "**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
+ languageOptions: {
+ parser: typescript,
+ parserOptions: {
+ project: "./tsconfig.test.json",
+ sourceType: "module",
+ },
+ },
+ },
+ {
+ files: ["**/*.{ts,tsx}"],
+ ignores: [
+ "**/__tests__/**/*.{ts,tsx}",
+ "**/*.test.{ts,tsx}",
+ "**/*.spec.{ts,tsx}",
+ "*.config.ts",
+ ],
+ languageOptions: {
+ parser: typescript,
+ parserOptions: {
+ project: "./tsconfig.json",
+ sourceType: "module",
+ },
+ },
+ plugins: {
+ "@typescript-eslint": tseslint,
+ "unused-imports": unusedImports,
+ prettier: eslintPluginPrettier,
+ },
+ rules: {
+ "@typescript-eslint/interface-name-prefix": "off",
+ "@typescript-eslint/explicit-function-return-type": "off",
+ "@typescript-eslint/explicit-module-boundary-types": "off",
+ "@typescript-eslint/no-explicit-any": "off",
+ "no-undefined": "off",
+ "@typescript-eslint/no-unused-vars": [
+ "error",
+ {
+ vars: "all",
+ varsIgnorePattern: "^_",
+ args: "after-used",
+ argsIgnorePattern: "^_",
+ },
+ ],
+ "unused-imports/no-unused-imports": "error",
+ ...eslintPluginPrettier.configs.recommended.rules,
+ },
+ },
+ prettier,
+];
diff --git a/packages/agno/package.json b/packages/agno/package.json
new file mode 100644
index 000000000..e85c94eca
--- /dev/null
+++ b/packages/agno/package.json
@@ -0,0 +1,71 @@
+{
+ "name": "@openuidev/agno",
+ "version": "0.0.1",
+ "description": "OpenUI client and AgentOS session adapters for Agno",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "type": "module",
+ "main": "dist/index.cjs",
+ "module": "dist/index.mjs",
+ "types": "dist/index.d.cts",
+ "sideEffects": false,
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.cts",
+ "default": "./dist/index.cjs"
+ }
+ }
+ },
+ "scripts": {
+ "test": "vitest run",
+ "build": "tsdown",
+ "watch": "tsdown --watch",
+ "typecheck": "tsc --noEmit",
+ "lint:check": "eslint ./src",
+ "lint:fix": "eslint ./src --fix",
+ "format:fix": "prettier --write ./src README.md",
+ "format:check": "prettier --check ./src README.md",
+ "check:publint": "publint",
+ "check:attw": "attw --pack .",
+ "prepare": "pnpm run build",
+ "prepublishOnly": "pnpm run check:publint && pnpm run check:attw",
+ "ci": "pnpm run typecheck && pnpm run test && pnpm run lint:check && pnpm run format:check"
+ },
+ "keywords": [
+ "openui",
+ "agno",
+ "agentos",
+ "ag-ui",
+ "generative-ui",
+ "streaming"
+ ],
+ "homepage": "https://openui.com",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/thesysdev/openui.git",
+ "directory": "packages/agno"
+ },
+ "bugs": {
+ "url": "https://github.com/thesysdev/openui/issues"
+ },
+ "author": "engineering@thesys.dev",
+ "peerDependencies": {
+ "@openuidev/react-headless": "workspace:^"
+ },
+ "devDependencies": {
+ "@openuidev/react-headless": "workspace:^",
+ "typescript": "catalog:",
+ "vitest": "^4.1.0"
+ }
+}
diff --git a/packages/agno/src/__tests__/adapter.test.ts b/packages/agno/src/__tests__/adapter.test.ts
new file mode 100644
index 000000000..7142ee49e
--- /dev/null
+++ b/packages/agno/src/__tests__/adapter.test.ts
@@ -0,0 +1,85 @@
+import { EventType } from "@openuidev/react-headless";
+import { describe, expect, it } from "vitest";
+import { agnoAGUIAdapter } from "../adapter";
+
+function aguiResponse(events: object[]) {
+ return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
+ headers: { "Content-Type": "text/event-stream" },
+ });
+}
+
+async function parse(events: object[]) {
+ const parsed = [];
+ for await (const event of agnoAGUIAdapter().parse(aguiResponse(events))) parsed.push(event);
+ return parsed;
+}
+
+describe("agnoAGUIAdapter", () => {
+ it("removes AgentOS lifecycle, state, raw events, and an empty tool parent", async () => {
+ const parsed = await parse([
+ { type: EventType.RUN_STARTED, threadId: "thread-1", runId: "run-1" },
+ { type: EventType.STATE_SNAPSHOT, snapshot: {} },
+ { type: EventType.TEXT_MESSAGE_START, messageId: "empty", role: "assistant" },
+ { type: EventType.TEXT_MESSAGE_END, messageId: "empty" },
+ {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: "call-1",
+ toolCallName: "get_quarterly_revenue",
+ parentMessageId: "empty",
+ },
+ { type: EventType.TOOL_CALL_ARGS, toolCallId: "call-1", delta: "{}" },
+ { type: EventType.TOOL_CALL_END, toolCallId: "call-1" },
+ {
+ type: EventType.TOOL_CALL_RESULT,
+ toolCallId: "call-1",
+ messageId: "call-1",
+ role: "tool",
+ content: '{"quarters":[]}',
+ },
+ { type: EventType.TEXT_MESSAGE_START, messageId: "answer", role: "assistant" },
+ { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "answer", delta: "root = Card([])" },
+ { type: EventType.TEXT_MESSAGE_END, messageId: "answer" },
+ { type: EventType.RUN_FINISHED, threadId: "thread-1", runId: "run-1" },
+ ]);
+
+ expect(parsed.map((event) => event.type)).toEqual([
+ EventType.TOOL_CALL_START,
+ EventType.TOOL_CALL_ARGS,
+ EventType.TOOL_CALL_END,
+ EventType.TOOL_CALL_RESULT,
+ EventType.TEXT_MESSAGE_START,
+ EventType.TEXT_MESSAGE_CONTENT,
+ EventType.TEXT_MESSAGE_END,
+ ]);
+ });
+
+ it("keeps non-text events buffered inside an empty envelope", async () => {
+ const parsed = await parse([
+ { type: EventType.TEXT_MESSAGE_START, messageId: "empty", role: "assistant" },
+ {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: "call-1",
+ toolCallName: "lookup",
+ },
+ { type: EventType.TEXT_MESSAGE_END, messageId: "empty" },
+ ]);
+
+ expect(parsed).toEqual([
+ expect.objectContaining({ type: EventType.TOOL_CALL_START, toolCallId: "call-1" }),
+ ]);
+ });
+
+ it("keeps a text envelope once meaningful content arrives", async () => {
+ const parsed = await parse([
+ { type: EventType.TEXT_MESSAGE_START, messageId: "answer", role: "assistant" },
+ { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "answer", delta: "hello" },
+ { type: EventType.TEXT_MESSAGE_END, messageId: "answer" },
+ ]);
+
+ expect(parsed.map((event) => event.type)).toEqual([
+ EventType.TEXT_MESSAGE_START,
+ EventType.TEXT_MESSAGE_CONTENT,
+ EventType.TEXT_MESSAGE_END,
+ ]);
+ });
+});
diff --git a/packages/agno/src/__tests__/llm.test.ts b/packages/agno/src/__tests__/llm.test.ts
new file mode 100644
index 000000000..bb10a0a9f
--- /dev/null
+++ b/packages/agno/src/__tests__/llm.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it, vi } from "vitest";
+import { createAgnoLLM } from "../llm";
+
+describe("createAgnoLLM", () => {
+ it("sends AgentOS extension containers and bearer authentication", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
+ const llm = createAgnoLLM({
+ url: "/agui",
+ token: "test-token",
+ forwardedProps: { user_id: "user-1" },
+ fetch: fetchMock,
+ });
+
+ await llm.send({
+ threadId: "thread-1",
+ messages: [{ id: "message-1", role: "user", content: "Hello" }],
+ signal: new AbortController().signal,
+ });
+
+ const [url, request] = fetchMock.mock.calls[0]!;
+ expect(url).toBe("/agui");
+ expect(request?.headers).toMatchObject({
+ Authorization: "Bearer test-token",
+ "Content-Type": "application/json",
+ });
+ expect(JSON.parse(request?.body as string)).toMatchObject({
+ threadId: "thread-1",
+ state: {},
+ forwardedProps: { user_id: "user-1" },
+ tools: [],
+ context: [],
+ });
+ });
+
+ it("lets an explicit Authorization header override the token", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
+ const llm = createAgnoLLM({
+ url: "/agui",
+ token: "ignored",
+ headers: { Authorization: "Bearer custom" },
+ fetch: fetchMock,
+ });
+
+ await llm.send({
+ threadId: "thread-1",
+ messages: [{ id: "message-1", role: "user", content: "Hello" }],
+ signal: new AbortController().signal,
+ });
+
+ expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
+ Authorization: "Bearer custom",
+ });
+ });
+});
diff --git a/packages/agno/src/__tests__/storage.test.ts b/packages/agno/src/__tests__/storage.test.ts
new file mode 100644
index 000000000..652074437
--- /dev/null
+++ b/packages/agno/src/__tests__/storage.test.ts
@@ -0,0 +1,176 @@
+import type { UserMessage } from "@openuidev/react-headless";
+import { describe, expect, it, vi } from "vitest";
+import { agnoHistoryToMessages, agnoStorage } from "../storage";
+
+function jsonResponse(value: unknown, status = 200) {
+ return Response.json(value, { status });
+}
+
+describe("agnoHistoryToMessages", () => {
+ it("maps AgentOS history and tool calls to canonical AG-UI messages", () => {
+ expect(
+ agnoHistoryToMessages(
+ [
+ { role: "system", content: "system" },
+ { role: "user", content: "hello" },
+ {
+ role: "assistant",
+ content: "",
+ tool_calls: [{ id: "call-1", name: "lookup", args: { city: "Delhi" } }],
+ },
+ { role: "tool", tool_call_id: "call-1", content: { temperature: 31 } },
+ { role: "assistant", content: "root = Card([])" },
+ ],
+ "thread-1",
+ ),
+ ).toEqual([
+ { id: "thread-1-message-0", role: "system", content: "system" },
+ { id: "thread-1-message-1", role: "user", content: "hello" },
+ {
+ id: "thread-1-message-2",
+ role: "assistant",
+ content: "",
+ toolCalls: [
+ {
+ id: "call-1",
+ type: "function",
+ function: { name: "lookup", arguments: '{"city":"Delhi"}' },
+ },
+ ],
+ },
+ {
+ id: "thread-1-message-3",
+ role: "tool",
+ toolCallId: "call-1",
+ content: '{"temperature":31}',
+ },
+ {
+ id: "thread-1-message-4",
+ role: "assistant",
+ content: "root = Card([])",
+ },
+ ]);
+ });
+});
+
+describe("agnoStorage", () => {
+ it("maps AgentOS session CRUD and pagination to OpenUI threads", async () => {
+ const requests: Array<{ url: string; init?: RequestInit }> = [];
+ const fetchMock = vi.fn(async (input, init) => {
+ const url = String(input);
+ requests.push({ url, init });
+
+ if (url.includes("page=2")) {
+ return jsonResponse({
+ data: [
+ {
+ session_id: "session-2",
+ session_name: "Existing session",
+ created_at: "2026-08-24T00:00:00Z",
+ },
+ ],
+ meta: { page: 2, total_pages: 3 },
+ });
+ }
+ if (init?.method === "POST" && url.endsWith("/sessions?type=agent&user_id=user-1")) {
+ return jsonResponse(
+ {
+ session_id: "session-new",
+ session_name: "Build a revenue dashboard",
+ created_at: "2026-08-24T00:00:00Z",
+ },
+ 201,
+ );
+ }
+ if (url.includes("/sessions/session-2/rename")) {
+ return jsonResponse({
+ session_id: "session-2",
+ session_name: "Renamed",
+ created_at: "2026-08-24T00:00:00Z",
+ });
+ }
+ if (url.includes("/sessions/session-2") && init?.method !== "DELETE") {
+ return jsonResponse({
+ session_id: "session-2",
+ session_name: "Existing session",
+ chat_history: [
+ { role: "user", content: "Hello" },
+ { role: "assistant", content: "root = Card([])" },
+ ],
+ });
+ }
+ return new Response(null, { status: 204 });
+ });
+
+ const storage = agnoStorage({
+ baseUrl: "https://agent.example",
+ entityType: "agent",
+ entityId: "openui-agent",
+ userId: "user-1",
+ pageSize: 10,
+ token: "test-token",
+ fetch: fetchMock,
+ });
+
+ await expect(storage.thread.listThreads("2")).resolves.toEqual({
+ threads: [
+ {
+ id: "session-2",
+ title: "Existing session",
+ createdAt: "2026-08-24T00:00:00Z",
+ },
+ ],
+ nextCursor: "3",
+ });
+
+ await expect(
+ storage.thread.createThread({
+ id: "message-1",
+ role: "user",
+ content: " Build a revenue dashboard ",
+ } as UserMessage),
+ ).resolves.toMatchObject({ id: "session-new", title: "Build a revenue dashboard" });
+
+ await expect(storage.thread.getMessages("session-2")).resolves.toEqual([
+ { id: "session-2-message-0", role: "user", content: "Hello" },
+ { id: "session-2-message-1", role: "assistant", content: "root = Card([])" },
+ ]);
+
+ await expect(
+ storage.thread.updateThread({
+ id: "session-2",
+ title: "Renamed",
+ createdAt: "2026-08-24T00:00:00Z",
+ }),
+ ).resolves.toMatchObject({ id: "session-2", title: "Renamed" });
+
+ await storage.thread.deleteThread("session-2");
+
+ expect(requests[0]?.url).toContain(
+ "/sessions?type=agent&user_id=user-1&component_id=openui-agent&limit=10&page=2",
+ );
+ expect(requests[1]?.init?.body).toBe(
+ JSON.stringify({
+ session_name: "Build a revenue dashboard",
+ user_id: "user-1",
+ agent_id: "openui-agent",
+ }),
+ );
+ expect(requests[3]?.init?.body).toBe(JSON.stringify({ session_name: "Renamed" }));
+ expect(requests[4]?.init?.method).toBe("DELETE");
+ expect(requests.every(({ init }) => init?.headers)).toBe(true);
+ });
+
+ it("surfaces AgentOS error details", async () => {
+ const storage = agnoStorage({
+ baseUrl: "https://agent.example",
+ entityType: "team",
+ entityId: "research-team",
+ fetch: vi
+ .fn()
+ .mockResolvedValue(jsonResponse({ detail: "Session database is unavailable" }, 503)),
+ });
+
+ await expect(storage.thread.listThreads()).rejects.toThrow("Session database is unavailable");
+ });
+});
diff --git a/packages/agno/src/adapter.ts b/packages/agno/src/adapter.ts
new file mode 100644
index 000000000..9fa0d4d9e
--- /dev/null
+++ b/packages/agno/src/adapter.ts
@@ -0,0 +1,86 @@
+import {
+ agUIAdapter,
+ EventType,
+ type AGUIEvent,
+ type StreamProtocolAdapter,
+} from "@openuidev/react-headless";
+
+const OPENUI_CHAT_EVENT_TYPES = new Set([
+ EventType.TEXT_MESSAGE_START,
+ EventType.TEXT_MESSAGE_CHUNK,
+ EventType.TEXT_MESSAGE_CONTENT,
+ EventType.TEXT_MESSAGE_END,
+ EventType.TOOL_CALL_START,
+ EventType.TOOL_CALL_CHUNK,
+ EventType.TOOL_CALL_ARGS,
+ EventType.TOOL_CALL_END,
+ EventType.TOOL_CALL_RESULT,
+ EventType.RUN_ERROR,
+]);
+
+/**
+ * Normalize Agno's AG-UI stream for OpenUI's current chat message processor.
+ *
+ * AgentOS emits lifecycle, state, and raw events that are valid AG-UI but are
+ * not chat messages. It also emits an empty assistant message as the required
+ * parent of a backend tool call. Forwarding those events to OpenUI currently
+ * materializes empty messages and can race a fast tool result. This adapter
+ * keeps the supported text/tool/error events and removes only empty text
+ * envelopes; AgentOS remains the owner of the run and tool lifecycle.
+ */
+export function agnoAGUIAdapter(): StreamProtocolAdapter {
+ const adapter = agUIAdapter();
+
+ return {
+ async *parse(response) {
+ let pendingTextStart: AGUIEvent | undefined;
+ let pendingEvents: AGUIEvent[] = [];
+
+ const flushBufferedEvents = function* () {
+ yield* pendingEvents;
+ pendingEvents = [];
+ };
+
+ for await (const event of adapter.parse(response)) {
+ if (!OPENUI_CHAT_EVENT_TYPES.has(event.type)) continue;
+
+ if (event.type === EventType.TEXT_MESSAGE_START) {
+ if (pendingTextStart) {
+ yield* flushBufferedEvents();
+ }
+ pendingTextStart = event;
+ pendingEvents = [];
+ continue;
+ }
+
+ if (!pendingTextStart) {
+ yield event;
+ continue;
+ }
+
+ if (
+ event.type === EventType.TEXT_MESSAGE_CHUNK ||
+ event.type === EventType.TEXT_MESSAGE_CONTENT
+ ) {
+ yield pendingTextStart;
+ pendingTextStart = undefined;
+ yield* flushBufferedEvents();
+ yield event;
+ continue;
+ }
+
+ if (event.type === EventType.TEXT_MESSAGE_END) {
+ pendingTextStart = undefined;
+ yield* flushBufferedEvents();
+ continue;
+ }
+
+ pendingEvents.push(event);
+ }
+
+ // Never discard tool/error events merely because AgentOS ended the
+ // transport before closing an empty parent text envelope.
+ yield* flushBufferedEvents();
+ },
+ };
+}
diff --git a/packages/agno/src/index.ts b/packages/agno/src/index.ts
new file mode 100644
index 000000000..99497cb9b
--- /dev/null
+++ b/packages/agno/src/index.ts
@@ -0,0 +1,5 @@
+export { agnoAGUIAdapter } from "./adapter";
+export { createAgnoLLM } from "./llm";
+export type { CreateAgnoLLMOptions } from "./llm";
+export { agnoHistoryToMessages, agnoStorage, createAgnoStorage } from "./storage";
+export type { AgnoEntityType, AgnoStorageOptions } from "./storage";
diff --git a/packages/agno/src/llm.ts b/packages/agno/src/llm.ts
new file mode 100644
index 000000000..2a9dc3304
--- /dev/null
+++ b/packages/agno/src/llm.ts
@@ -0,0 +1,51 @@
+import {
+ fetchLLM,
+ type ChatLLM,
+ type FetchLLMOptions,
+ type StreamProtocolAdapter,
+} from "@openuidev/react-headless";
+import { agnoAGUIAdapter } from "./adapter";
+
+export interface CreateAgnoLLMOptions extends Omit {
+ /** Bearer token for an authenticated AgentOS. Explicit Authorization headers win. */
+ token?: string;
+ /** Initial AG-UI state sent to AgentOS. Defaults to an empty object. */
+ state?: Record;
+ /** AgentOS forwarding fields such as user_id. Defaults to an empty object. */
+ forwardedProps?: Record;
+ /** Additional RunAgentInput fields merged before the required Agno fields. */
+ body?: Record;
+ /** Override the Agno-aware stream adapter. */
+ streamAdapter?: StreamProtocolAdapter;
+}
+
+/**
+ * Create an OpenUI ChatLLM backed by an Agno AgentOS AG-UI endpoint.
+ *
+ * The helper supplies the extension containers required by AgentOS and uses
+ * the Agno-aware AG-UI adapter by default. AgentOS still owns model execution,
+ * tools, sessions, and authorization; OpenUI owns the browser UI.
+ */
+export function createAgnoLLM({
+ token,
+ state = {},
+ forwardedProps = {},
+ body,
+ headers,
+ streamAdapter = agnoAGUIAdapter(),
+ ...options
+}: CreateAgnoLLMOptions): ChatLLM {
+ return fetchLLM({
+ ...options,
+ streamAdapter,
+ headers: {
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ ...headers,
+ },
+ body: {
+ ...body,
+ state,
+ forwardedProps,
+ },
+ });
+}
diff --git a/packages/agno/src/storage.ts b/packages/agno/src/storage.ts
new file mode 100644
index 000000000..968e01739
--- /dev/null
+++ b/packages/agno/src/storage.ts
@@ -0,0 +1,301 @@
+import type {
+ ChatStorage,
+ Message,
+ Thread,
+ ToolCall,
+ UserMessage,
+} from "@openuidev/react-headless";
+
+export type AgnoEntityType = "agent" | "team";
+
+export interface AgnoStorageOptions {
+ /** AgentOS origin, optionally including a path prefix. */
+ baseUrl: string;
+ /** Whether sessions belong to an Agno agent or team. */
+ entityType: AgnoEntityType;
+ /** Agent or team id used to filter and create sessions. */
+ entityId: string;
+ /** Anonymous AgentOS user id. Authenticated AgentOS instances derive it from the token. */
+ userId?: string;
+ /** Optional AgentOS database id. */
+ dbId?: string;
+ /** Optional AgentOS session table name. */
+ table?: string;
+ /** Session page size. Defaults to 20. */
+ pageSize?: number;
+ /** Bearer token for AgentOS. Explicit Authorization headers win. */
+ token?: string;
+ /** Extra headers merged into every AgentOS session request. */
+ headers?: Record;
+ /** Override fetch for tests, proxies, or custom authentication. */
+ fetch?: typeof fetch;
+}
+
+interface AgnoSessionSummary {
+ session_id: string;
+ session_name?: string;
+ created_at?: string | number;
+ updated_at?: string | number;
+}
+
+interface AgnoSessionDetail extends AgnoSessionSummary {
+ chat_history?: unknown[];
+}
+
+interface AgnoSessionPage {
+ data?: AgnoSessionSummary[];
+ meta?: {
+ page?: number;
+ total_pages?: number;
+ };
+}
+
+type UnknownRecord = Record;
+
+function isRecord(value: unknown): value is UnknownRecord {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function joinUrl(baseUrl: string, path: string): string {
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
+}
+
+function withQuery(url: string, values: Record): string {
+ const query = new URLSearchParams();
+ for (const [key, value] of Object.entries(values)) {
+ if (value !== undefined && value !== "") query.set(key, String(value));
+ }
+ const encoded = query.toString();
+ return encoded ? `${url}?${encoded}` : url;
+}
+
+function sessionToThread(session: AgnoSessionSummary): Thread {
+ return {
+ id: session.session_id,
+ title: session.session_name?.trim() || "New conversation",
+ createdAt: session.created_at ?? session.updated_at ?? Date.now(),
+ };
+}
+
+function contentToString(value: unknown): string {
+ if (typeof value === "string") return value;
+ if (value === undefined || value === null) return "";
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+}
+
+function messageTitle(message: UserMessage): string {
+ const content = message.content;
+ const text =
+ typeof content === "string"
+ ? content
+ : (content.find((part) => part.type === "text")?.text ?? "New conversation");
+ const title = text.replace(/\s+/g, " ").trim();
+ return title ? title.slice(0, 80) : "New conversation";
+}
+
+function normalizeToolCalls(value: unknown): ToolCall[] | undefined {
+ if (!Array.isArray(value)) return undefined;
+ const calls = value.flatMap((item, index): ToolCall[] => {
+ if (!isRecord(item)) return [];
+ const fn = isRecord(item["function"]) ? item["function"] : undefined;
+ const name =
+ typeof fn?.["name"] === "string"
+ ? fn["name"]
+ : typeof item["name"] === "string"
+ ? item["name"]
+ : "tool";
+ const rawArguments = fn?.["arguments"] ?? item["arguments"] ?? item["args"] ?? {};
+ const args = typeof rawArguments === "string" ? rawArguments : contentToString(rawArguments);
+ return [
+ {
+ id: typeof item["id"] === "string" ? item["id"] : `tool-call-${index}`,
+ type: "function",
+ function: { name, arguments: args },
+ },
+ ];
+ });
+ return calls.length > 0 ? calls : undefined;
+}
+
+/** Convert AgentOS chat_history records into OpenUI's canonical AG-UI messages. */
+export function agnoHistoryToMessages(history: unknown, sessionId = "session"): Message[] {
+ if (!Array.isArray(history)) return [];
+
+ return history.flatMap((entry, index): Message[] => {
+ if (!isRecord(entry) || typeof entry["role"] !== "string") return [];
+ const id =
+ typeof entry["id"] === "string"
+ ? entry["id"]
+ : typeof entry["message_id"] === "string"
+ ? entry["message_id"]
+ : `${sessionId}-message-${index}`;
+ const content = contentToString(entry["content"]);
+
+ switch (entry["role"]) {
+ case "human":
+ case "user":
+ return [{ id, role: "user", content }];
+ case "ai":
+ case "assistant": {
+ const toolCalls = normalizeToolCalls(entry["tool_calls"]);
+ return [
+ {
+ id,
+ role: "assistant",
+ content,
+ ...(toolCalls ? { toolCalls } : {}),
+ },
+ ];
+ }
+ case "tool":
+ return [
+ {
+ id,
+ role: "tool",
+ toolCallId:
+ typeof entry["tool_call_id"] === "string"
+ ? entry["tool_call_id"]
+ : `tool-call-${index}`,
+ content,
+ },
+ ];
+ case "system":
+ return [{ id, role: "system", content }];
+ case "developer":
+ return [{ id, role: "developer", content }];
+ default:
+ return [];
+ }
+ });
+}
+
+/**
+ * Store OpenUI conversations in AgentOS rather than in a second chat database.
+ *
+ * The adapter maps OpenUI threads to AgentOS sessions and reloads canonical
+ * messages from each session's chat_history. AgentOS remains authoritative for
+ * ownership, persistence, session history, and authorization.
+ */
+export function agnoStorage({
+ baseUrl,
+ entityType,
+ entityId,
+ userId,
+ dbId,
+ table,
+ pageSize = 20,
+ token,
+ headers,
+ fetch: customFetch,
+}: AgnoStorageOptions): ChatStorage {
+ const fetchImpl = customFetch ?? globalThis.fetch.bind(globalThis);
+ const entityKey = entityType === "agent" ? "agent_id" : "team_id";
+
+ const queryValues = (extra: Record = {}) => ({
+ type: entityType,
+ user_id: userId,
+ db_id: dbId,
+ table,
+ ...extra,
+ });
+
+ const request = async (url: string, init?: RequestInit): Promise => {
+ const response = await fetchImpl(url, {
+ ...init,
+ headers: {
+ ...(init?.body ? { "Content-Type": "application/json" } : {}),
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ ...headers,
+ ...init?.headers,
+ },
+ });
+ if (!response.ok) {
+ const detail = await response
+ .clone()
+ .json()
+ .then((body: unknown) => (isRecord(body) ? body["detail"] : undefined))
+ .catch(() => undefined);
+ throw new Error(
+ `agnoStorage: ${init?.method ?? "GET"} ${url} failed: ${response.status}${
+ typeof detail === "string" ? ` ${detail}` : ""
+ }`,
+ );
+ }
+ return response;
+ };
+
+ return {
+ thread: {
+ async listThreads(cursor) {
+ const page = cursor && Number.isFinite(Number(cursor)) ? Math.max(1, Number(cursor)) : 1;
+ const url = withQuery(
+ joinUrl(baseUrl, "/sessions"),
+ queryValues({
+ component_id: entityId,
+ limit: pageSize,
+ page,
+ sort_by: "updated_at",
+ sort_order: "desc",
+ }),
+ );
+ const response = await request(url);
+ const payload = (await response.json()) as AgnoSessionPage;
+ const currentPage = payload.meta?.page ?? page;
+ const totalPages = payload.meta?.total_pages ?? currentPage;
+ return {
+ threads: (payload.data ?? []).map(sessionToThread),
+ ...(currentPage < totalPages ? { nextCursor: String(currentPage + 1) } : {}),
+ };
+ },
+
+ async createThread(firstMessage) {
+ const url = withQuery(joinUrl(baseUrl, "/sessions"), queryValues());
+ const response = await request(url, {
+ method: "POST",
+ body: JSON.stringify({
+ session_name: messageTitle(firstMessage),
+ ...(userId ? { user_id: userId } : {}),
+ [entityKey]: entityId,
+ }),
+ });
+ return sessionToThread((await response.json()) as AgnoSessionDetail);
+ },
+
+ async getMessages(threadId) {
+ const url = withQuery(
+ joinUrl(baseUrl, `/sessions/${encodeURIComponent(threadId)}`),
+ queryValues(),
+ );
+ const response = await request(url);
+ const session = (await response.json()) as AgnoSessionDetail;
+ return agnoHistoryToMessages(session.chat_history, threadId);
+ },
+
+ async updateThread(thread) {
+ const url = withQuery(
+ joinUrl(baseUrl, `/sessions/${encodeURIComponent(thread.id)}/rename`),
+ queryValues(),
+ );
+ const response = await request(url, {
+ method: "POST",
+ body: JSON.stringify({ session_name: thread.title }),
+ });
+ return sessionToThread((await response.json()) as AgnoSessionDetail);
+ },
+
+ async deleteThread(threadId) {
+ const url = withQuery(
+ joinUrl(baseUrl, `/sessions/${encodeURIComponent(threadId)}`),
+ queryValues(),
+ );
+ await request(url, { method: "DELETE" });
+ },
+ },
+ };
+}
+
+export const createAgnoStorage = agnoStorage;
diff --git a/packages/agno/tsconfig.json b/packages/agno/tsconfig.json
new file mode 100644
index 000000000..8645f69b9
--- /dev/null
+++ b/packages/agno/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "../../tsconfig.json",
+ "include": ["src/**/*"],
+ "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@openuidev/react-headless": ["../react-headless/dist/index.d.mts"]
+ },
+ "moduleResolution": "bundler",
+ "module": "ESNext",
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "noEmit": true
+ }
+}
diff --git a/packages/agno/tsconfig.test.json b/packages/agno/tsconfig.test.json
new file mode 100644
index 000000000..60b3001d5
--- /dev/null
+++ b/packages/agno/tsconfig.test.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["dist", "node_modules"]
+}
diff --git a/packages/agno/tsdown.config.ts b/packages/agno/tsdown.config.ts
new file mode 100644
index 000000000..bd5f1a3e1
--- /dev/null
+++ b/packages/agno/tsdown.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "tsdown";
+
+export default defineConfig({
+ entry: ["src/index.ts"],
+ format: ["esm", "cjs"],
+ dts: true,
+ sourcemap: true,
+ target: "es2022",
+ outDir: "dist",
+ clean: true,
+ deps: {
+ neverBundle: [/^(?![./]|[A-Za-z]:[/\\])/],
+ },
+});
diff --git a/packages/agno/vitest.config.ts b/packages/agno/vitest.config.ts
new file mode 100644
index 000000000..2900ab354
--- /dev/null
+++ b/packages/agno/vitest.config.ts
@@ -0,0 +1,14 @@
+import { fileURLToPath } from "node:url";
+
+export default {
+ resolve: {
+ alias: {
+ "@openuidev/react-headless": fileURLToPath(
+ new URL("../react-headless/src/index.ts", import.meta.url),
+ ),
+ },
+ },
+ test: {
+ environment: "node",
+ },
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d64fbef46..167060393 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1360,6 +1360,18 @@ importers:
specifier: ^5
version: 5.9.3
+ packages/agno:
+ devDependencies:
+ '@openuidev/react-headless':
+ specifier: workspace:^
+ version: link:../react-headless
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
+ vitest:
+ specifier: ^4.1.0
+ version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))
+
packages/assistant-ui:
dependencies:
'@openuidev/react-headless':
From ba3e79e2d3b33c2c91f5487d3f8620a5951d372a Mon Sep 17 00:00:00 2001
From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com>
Date: Tue, 25 Aug 2026 18:39:47 +0530
Subject: [PATCH 2/2] feat: add Agno OpenUI streaming and HITL support
---
packages/agno/README.md | 74 +++-
packages/agno/package.json | 9 +-
packages/agno/src/__tests__/adapter.test.ts | 41 +++
packages/agno/src/__tests__/llm.test.ts | 3 +-
.../agno/src/__tests__/prompt-openui.test.ts | 50 +++
packages/agno/src/__tests__/storage.test.ts | 43 +++
packages/agno/src/adapter.ts | 71 ++--
packages/agno/src/index.ts | 11 +
packages/agno/src/openui-fence.ts | 69 ++++
packages/agno/src/prompt-openui.tsx | 154 ++++++++
packages/agno/src/storage.ts | 9 +-
packages/agno/tsconfig.json | 2 +-
.../react-headless/src/adapters/fetchLLM.ts | 5 +-
.../react-headless/src/hooks/useThread.ts | 1 +
packages/react-headless/src/index.ts | 1 +
.../store/__tests__/createChatStore.test.ts | 66 ++++
.../src/store/artifactRendererTypes.ts | 4 +
.../src/store/createChatStore.ts | 334 ++++++++++--------
packages/react-headless/src/store/types.ts | 5 +-
.../tool-renderer/ToolActivityRenderer.tsx | 2 +
pnpm-lock.yaml | 12 +
21 files changed, 773 insertions(+), 193 deletions(-)
create mode 100644 packages/agno/src/__tests__/prompt-openui.test.ts
create mode 100644 packages/agno/src/openui-fence.ts
create mode 100644 packages/agno/src/prompt-openui.tsx
diff --git a/packages/agno/README.md b/packages/agno/README.md
index fb68afd65..20e3dd286 100644
--- a/packages/agno/README.md
+++ b/packages/agno/README.md
@@ -13,7 +13,7 @@ The integration is intentionally complementary:
## Install
```bash
-pnpm add @openuidev/agno @openuidev/react-ui
+pnpm add @openuidev/agno @openuidev/react-headless @openuidev/react-lang @openuidev/react-ui react
```
Import OpenUI styles once:
@@ -25,12 +25,13 @@ Import OpenUI styles once:
## Connect AgentInterface to AgentOS
```tsx
-import { createAgnoLLM, agnoStorage } from "@openuidev/agno";
-import { AgentInterface } from "@openuidev/react-ui";
+import { agnoOpenUIPromptRenderer, createAgnoLLM, agnoStorage } from "@openuidev/agno";
+import { AgentInterface, openuiChatLibrary } from "@openuidev/react-ui";
const llm = createAgnoLLM({
url: "http://localhost:7777/agui",
forwardedProps: { user_id: "demo-user" },
+ context: [{ description: "openui_client", value: "true" }],
});
const storage = agnoStorage({
@@ -41,7 +42,14 @@ const storage = agnoStorage({
});
export function Chat() {
- return ;
+ return (
+
+ );
}
```
@@ -54,13 +62,18 @@ omit `userId`/`forwardedProps.user_id`; AgentOS derives identity from the token.
the Agno-aware stream adapter.
- `agnoAGUIAdapter()` removes non-chat lifecycle/state events and Agno's empty
tool-parent text envelope while retaining streamed text, tools, and errors.
+ When assistant text is fenced as `openui`, it removes only that wrapper
+ incrementally so OpenUI receives the inner language as progressive deltas.
- `agnoStorage()` maps OpenUI threads to AgentOS `/sessions` APIs and reloads
- messages from AgentOS `chat_history`.
+ messages from AgentOS `chat_history`. It removes the same optional wrapper
+ during history conversion, so a reloaded thread follows the live render path.
- The mapping is deliberately 1:1: the OpenUI `thread.id` is the AgentOS
`session_id`, so chat, persistence, inspection, and operational tooling all
address the same conversation without a translation table.
- `agnoHistoryToMessages()` exposes the tolerant history conversion separately
for custom storage implementations.
+- `agnoOpenUIPromptRenderer` renders the Agno `prompt_openui` HITL tool.
+ Submissions become AG-UI tool results that resume the paused AgentOS run.
The package does not run an agent, proxy model keys, or create a second source
of conversation truth.
@@ -76,12 +89,31 @@ from agno.db.sqlite import SqliteDb
from agno.models.openai import OpenAIResponses
from agno.os import AgentOS
from agno.os.interfaces.agui import AGUI
+from agno.tools import tool
+
+@tool(external_execution=True, external_execution_silent=True)
+def prompt_openui(ui: str, fallback_markdown: str) -> str:
+ """Render a form or choice and wait for its submission."""
+ return fallback_markdown
+
+def instructions(run_context=None):
+ dependencies = getattr(run_context, "dependencies", None) or {}
+ if dependencies.get("openui_client") is True:
+ return [
+ openui_system_prompt,
+ """For complete visual answers, return exactly one Markdown fence labeled
+ openui. Put root first and do not add text outside the fence. Use
+ prompt_openui only when a user action must pause and resume the run;
+ its ui argument is raw OpenUI Lang without a fence.""",
+ ]
+ return ["Respond in ordinary text or Markdown. Do not call prompt_openui."]
agent = Agent(
id="openui-assistant",
model=OpenAIResponses(id="gpt-5.5"),
db=SqliteDb(id="openui", db_file="tmp/openui.db"),
- instructions=[openui_system_prompt],
+ tools=[prompt_openui],
+ instructions=instructions,
add_history_to_context=True,
)
@@ -89,13 +121,33 @@ agent_os = AgentOS(agents=[agent], interfaces=[AGUI(agent=agent)])
app = agent_os.get_app()
```
+The fence is a single, lossless payload. AgentOS stores and displays it as a
+readable Markdown code block; it does not need to render the interface. The
+Agno adapter drops only the opening and closing fence as bytes arrive, so the
+same payload progressively renders in OpenUI and reloads from session history.
+
+`prompt_openui` is reserved for a real human-in-the-loop boundary: AgentOS
+persists a paused run, OpenUI collects the required action and form state, and
+the package submits a trailing tool message to resume that same run. A database
+is required for AgentOS to reload and resume paused runs. Current AgentOS emits
+the prompt tool arguments after the call is complete, so the prompt interface
+appears as one tool payload rather than progressively. Its subsequent assistant
+answer uses the normal fenced streaming path.
+
+For one agent shared by OpenUI and native AgentOS chat, use a callable Agno
+instruction function. The example marks AG-UI requests with the transient
+`openui_client` context dependency: those runs receive the generated component
+prompt, fenced output rule, and HITL tool rule; native AgentOS runs receive
+ordinary Markdown rules and do not call the UI tool.
+
The local `examples/agno-chat` workspace contains a runnable client, a real
AgentOS server, and a deterministic no-key development harness.
## Current scope
-This first local implementation supports streamed OpenUI responses, backend
-tool timelines, authentication headers, agents/teams, and AgentOS-backed
-conversation persistence. Native rendering and resumption of Agno HITL/client
-tools is the next integration layer rather than something this package claims
-to support already.
+This local implementation supports progressively streamed assistant OpenUI
+Lang, paused human tools and resumption, backend tool timelines,
+authentication headers, agents/teams, and
+AgentOS-backed conversation persistence. The stock AgentOS web app does not render
+OpenUI Lang; it can inspect the fenced source while native AgentOS chat stays on
+the ordinary text/Markdown path.
diff --git a/packages/agno/package.json b/packages/agno/package.json
index e85c94eca..0cd4106e3 100644
--- a/packages/agno/package.json
+++ b/packages/agno/package.json
@@ -61,10 +61,17 @@
},
"author": "engineering@thesys.dev",
"peerDependencies": {
- "@openuidev/react-headless": "workspace:^"
+ "@openuidev/react-headless": "workspace:^",
+ "@openuidev/react-lang": "workspace:^",
+ "@openuidev/react-ui": "workspace:^",
+ "react": "catalog:"
},
"devDependencies": {
"@openuidev/react-headless": "workspace:^",
+ "@openuidev/react-lang": "workspace:^",
+ "@openuidev/react-ui": "workspace:^",
+ "@types/react": "catalog:",
+ "react": "catalog:",
"typescript": "catalog:",
"vitest": "^4.1.0"
}
diff --git a/packages/agno/src/__tests__/adapter.test.ts b/packages/agno/src/__tests__/adapter.test.ts
index 7142ee49e..00defcfc1 100644
--- a/packages/agno/src/__tests__/adapter.test.ts
+++ b/packages/agno/src/__tests__/adapter.test.ts
@@ -82,4 +82,45 @@ describe("agnoAGUIAdapter", () => {
EventType.TEXT_MESSAGE_END,
]);
});
+
+ it("incrementally unwraps fenced OpenUI Lang across arbitrary delta boundaries", async () => {
+ const content = 'root = Card([title])\ntitle = TextContent("Streaming")';
+ const fenced = `\`\`\`openui\n${content}\n\`\`\``;
+ const chunks = [fenced.slice(0, 2), fenced.slice(2, 9), ...fenced.slice(9).match(/.{1,7}/gs)!];
+ const parsed = await parse([
+ { type: EventType.TEXT_MESSAGE_START, messageId: "answer", role: "assistant" },
+ ...chunks.map((delta) => ({
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: "answer",
+ delta,
+ })),
+ { type: EventType.TEXT_MESSAGE_END, messageId: "answer" },
+ ]);
+
+ expect(
+ parsed
+ .filter((event) => event.type === EventType.TEXT_MESSAGE_CONTENT)
+ .map((event) => event.delta)
+ .join(""),
+ ).toBe(content);
+ expect(
+ parsed.filter((event) => event.type === EventType.TEXT_MESSAGE_CONTENT).length,
+ ).toBeGreaterThan(1);
+ });
+
+ it("leaves ordinary streamed assistant text unchanged", async () => {
+ const parsed = await parse([
+ { type: EventType.TEXT_MESSAGE_START, messageId: "answer", role: "assistant" },
+ { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "answer", delta: "Regular " },
+ { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "answer", delta: "Markdown" },
+ { type: EventType.TEXT_MESSAGE_END, messageId: "answer" },
+ ]);
+
+ expect(
+ parsed
+ .filter((event) => event.type === EventType.TEXT_MESSAGE_CONTENT)
+ .map((event) => event.delta)
+ .join(""),
+ ).toBe("Regular Markdown");
+ });
});
diff --git a/packages/agno/src/__tests__/llm.test.ts b/packages/agno/src/__tests__/llm.test.ts
index bb10a0a9f..4bd312f11 100644
--- a/packages/agno/src/__tests__/llm.test.ts
+++ b/packages/agno/src/__tests__/llm.test.ts
@@ -8,6 +8,7 @@ describe("createAgnoLLM", () => {
url: "/agui",
token: "test-token",
forwardedProps: { user_id: "user-1" },
+ context: [{ description: "openui_client", value: "true" }],
fetch: fetchMock,
});
@@ -28,7 +29,7 @@ describe("createAgnoLLM", () => {
state: {},
forwardedProps: { user_id: "user-1" },
tools: [],
- context: [],
+ context: [{ description: "openui_client", value: "true" }],
});
});
diff --git a/packages/agno/src/__tests__/prompt-openui.test.ts b/packages/agno/src/__tests__/prompt-openui.test.ts
new file mode 100644
index 000000000..a946a1cc5
--- /dev/null
+++ b/packages/agno/src/__tests__/prompt-openui.test.ts
@@ -0,0 +1,50 @@
+import { BuiltinActionType } from "@openuidev/react-lang";
+import { describe, expect, it } from "vitest";
+import {
+ AGNO_OPENUI_PROMPT_TOOL_NAME,
+ createAgnoOpenUIPromptRenderer,
+ parseAgnoOpenUIPrompt,
+} from "../prompt-openui";
+
+describe("Agno OpenUI prompt renderer", () => {
+ it("matches the conventional AgentOS tool name", () => {
+ expect(createAgnoOpenUIPromptRenderer().toolName).toBe(AGNO_OPENUI_PROMPT_TOOL_NAME);
+ });
+
+ it("recovers a streamed ui argument", () => {
+ expect(
+ parseAgnoOpenUIPrompt({
+ args: '{"ui":"root = Card([title])\\ntitle = TextContent(\\"Project',
+ response: null,
+ }),
+ ).toMatchObject({ ui: 'root = Card([title])\ntitle = TextContent("Project' });
+ });
+
+ it("hydrates submitted form state from the AgentOS tool result", () => {
+ expect(
+ parseAgnoOpenUIPrompt({
+ args: JSON.stringify({ ui: "root = Card([])" }),
+ response: JSON.stringify({
+ type: BuiltinActionType.ContinueConversation,
+ message: "Submit project estimate",
+ params: {},
+ formName: "project_estimate",
+ formState: { project_estimate: { project_name: { value: "Aurora" } } },
+ }),
+ }),
+ ).toEqual({
+ ui: "root = Card([])",
+ result: {
+ type: BuiltinActionType.ContinueConversation,
+ message: "Submit project estimate",
+ params: {},
+ formName: "project_estimate",
+ formState: { project_estimate: { project_name: { value: "Aurora" } } },
+ },
+ });
+ });
+
+ it("rejects calls without an OpenUI program", () => {
+ expect(parseAgnoOpenUIPrompt({ args: "{}", response: null })).toBeNull();
+ });
+});
diff --git a/packages/agno/src/__tests__/storage.test.ts b/packages/agno/src/__tests__/storage.test.ts
index 652074437..d02a9e8b8 100644
--- a/packages/agno/src/__tests__/storage.test.ts
+++ b/packages/agno/src/__tests__/storage.test.ts
@@ -1,5 +1,6 @@
import type { UserMessage } from "@openuidev/react-headless";
import { describe, expect, it, vi } from "vitest";
+import { stripOpenUIFence } from "../openui-fence";
import { agnoHistoryToMessages, agnoStorage } from "../storage";
function jsonResponse(value: unknown, status = 200) {
@@ -51,6 +52,48 @@ describe("agnoHistoryToMessages", () => {
},
]);
});
+
+ it("unwraps AgentOS Markdown source for OpenUI history reloads", () => {
+ expect(
+ agnoHistoryToMessages(
+ [
+ {
+ role: "assistant",
+ content: '```openui\nroot = Card([])\ntitle = TextContent("Saved")\n```',
+ },
+ ],
+ "thread-1",
+ ),
+ ).toEqual([
+ {
+ id: "thread-1-message-0",
+ role: "assistant",
+ content: 'root = Card([])\ntitle = TextContent("Saved")',
+ },
+ ]);
+ });
+
+ it("hides AgentOS dependency context appended to stored user messages", () => {
+ expect(
+ agnoHistoryToMessages(
+ [
+ {
+ role: "user",
+ content:
+ 'Build a chart. \n{ "openui_client": true }\n',
+ },
+ ],
+ "thread-1",
+ ),
+ ).toEqual([{ id: "thread-1-message-0", role: "user", content: "Build a chart." }]);
+ });
+});
+
+describe("stripOpenUIFence", () => {
+ it("is lossless for ordinary content and tolerant of an interrupted fenced stream", () => {
+ expect(stripOpenUIFence("root = Card([])")).toBe("root = Card([])");
+ expect(stripOpenUIFence("```openui\nroot = Card([])")).toBe("root = Card([])");
+ });
});
describe("agnoStorage", () => {
diff --git a/packages/agno/src/adapter.ts b/packages/agno/src/adapter.ts
index 9fa0d4d9e..a1d7f3443 100644
--- a/packages/agno/src/adapter.ts
+++ b/packages/agno/src/adapter.ts
@@ -4,6 +4,7 @@ import {
type AGUIEvent,
type StreamProtocolAdapter,
} from "@openuidev/react-headless";
+import { OpenUIFenceStream } from "./openui-fence";
const OPENUI_CHAT_EVENT_TYPES = new Set([
EventType.TEXT_MESSAGE_START,
@@ -25,35 +26,49 @@ const OPENUI_CHAT_EVENT_TYPES = new Set([
* not chat messages. It also emits an empty assistant message as the required
* parent of a backend tool call. Forwarding those events to OpenUI currently
* materializes empty messages and can race a fast tool result. This adapter
- * keeps the supported text/tool/error events and removes only empty text
- * envelopes; AgentOS remains the owner of the run and tool lifecycle.
+ * keeps the supported text/tool/error events, removes empty text envelopes,
+ * and incrementally unwraps fenced OpenUI Lang. AgentOS can therefore retain
+ * readable Markdown source while OpenUI receives the raw language deltas.
*/
export function agnoAGUIAdapter(): StreamProtocolAdapter {
const adapter = agUIAdapter();
return {
async *parse(response) {
- let pendingTextStart: AGUIEvent | undefined;
- let pendingEvents: AGUIEvent[] = [];
+ let textState:
+ | {
+ start: AGUIEvent;
+ started: boolean;
+ pendingEvents: AGUIEvent[];
+ fence: OpenUIFenceStream;
+ }
+ | undefined;
- const flushBufferedEvents = function* () {
- yield* pendingEvents;
- pendingEvents = [];
+ const startText = function* () {
+ if (!textState || textState.started) return;
+ yield textState.start;
+ yield* textState.pendingEvents;
+ textState.pendingEvents = [];
+ textState.started = true;
};
for await (const event of adapter.parse(response)) {
if (!OPENUI_CHAT_EVENT_TYPES.has(event.type)) continue;
if (event.type === EventType.TEXT_MESSAGE_START) {
- if (pendingTextStart) {
- yield* flushBufferedEvents();
+ if (textState) {
+ yield* textState.pendingEvents;
}
- pendingTextStart = event;
- pendingEvents = [];
+ textState = {
+ start: event,
+ started: false,
+ pendingEvents: [],
+ fence: new OpenUIFenceStream(),
+ };
continue;
}
- if (!pendingTextStart) {
+ if (!textState) {
yield event;
continue;
}
@@ -62,25 +77,41 @@ export function agnoAGUIAdapter(): StreamProtocolAdapter {
event.type === EventType.TEXT_MESSAGE_CHUNK ||
event.type === EventType.TEXT_MESSAGE_CONTENT
) {
- yield pendingTextStart;
- pendingTextStart = undefined;
- yield* flushBufferedEvents();
- yield event;
+ const delta = textState.fence.push(event.delta ?? "");
+ if (delta) {
+ yield* startText();
+ yield { ...event, delta };
+ }
continue;
}
if (event.type === EventType.TEXT_MESSAGE_END) {
- pendingTextStart = undefined;
- yield* flushBufferedEvents();
+ const delta = textState.fence.finish();
+ if (delta) {
+ yield* startText();
+ yield {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: event.messageId,
+ delta,
+ };
+ }
+ if (textState.started) {
+ yield* textState.pendingEvents;
+ yield event;
+ } else {
+ yield* textState.pendingEvents;
+ }
+ textState = undefined;
continue;
}
- pendingEvents.push(event);
+ if (textState.started) yield event;
+ else textState.pendingEvents.push(event);
}
// Never discard tool/error events merely because AgentOS ended the
// transport before closing an empty parent text envelope.
- yield* flushBufferedEvents();
+ if (textState) yield* textState.pendingEvents;
},
};
}
diff --git a/packages/agno/src/index.ts b/packages/agno/src/index.ts
index 99497cb9b..0056f8782 100644
--- a/packages/agno/src/index.ts
+++ b/packages/agno/src/index.ts
@@ -1,5 +1,16 @@
export { agnoAGUIAdapter } from "./adapter";
export { createAgnoLLM } from "./llm";
export type { CreateAgnoLLMOptions } from "./llm";
+export { stripOpenUIFence } from "./openui-fence";
+export {
+ AGNO_OPENUI_PROMPT_TOOL_NAME,
+ agnoOpenUIPromptRenderer,
+ createAgnoOpenUIPromptRenderer,
+ parseAgnoOpenUIPrompt,
+} from "./prompt-openui";
+export type {
+ AgnoOpenUIActionResult,
+ CreateAgnoOpenUIPromptRendererOptions,
+} from "./prompt-openui";
export { agnoHistoryToMessages, agnoStorage, createAgnoStorage } from "./storage";
export type { AgnoEntityType, AgnoStorageOptions } from "./storage";
diff --git a/packages/agno/src/openui-fence.ts b/packages/agno/src/openui-fence.ts
new file mode 100644
index 000000000..34845266f
--- /dev/null
+++ b/packages/agno/src/openui-fence.ts
@@ -0,0 +1,69 @@
+const OPENUI_FENCE_OPENERS = [
+ "```openui\n",
+ "```openui\r\n",
+ "```openui-lang\n",
+ "```openui-lang\r\n",
+] as const;
+
+const MAX_CLOSING_FENCE_LENGTH = "\r\n```\r\n".length;
+
+/**
+ * Remove an optional Markdown fence used to make OpenUI Lang readable in
+ * AgentOS. Non-fenced content is returned byte-for-byte unchanged.
+ */
+export function stripOpenUIFence(content: string): string {
+ const opener = OPENUI_FENCE_OPENERS.find((candidate) => content.startsWith(candidate));
+ if (!opener) return content;
+
+ const body = content.slice(opener.length);
+ const closingFence = body.match(/\r?\n```[\t ]*(?:\r?\n)?$/);
+ return closingFence ? body.slice(0, -closingFence[0].length) : body;
+}
+
+/** Incrementally remove the AgentOS Markdown wrapper without buffering the UI. */
+export class OpenUIFenceStream {
+ private mode: "detect" | "fenced" | "passthrough" = "detect";
+ private buffer = "";
+
+ push(delta: string): string {
+ if (this.mode === "passthrough") return delta;
+
+ this.buffer += delta;
+
+ if (this.mode === "detect") {
+ const opener = OPENUI_FENCE_OPENERS.find((candidate) => this.buffer.startsWith(candidate));
+ if (opener) {
+ this.mode = "fenced";
+ this.buffer = this.buffer.slice(opener.length);
+ } else if (OPENUI_FENCE_OPENERS.some((candidate) => candidate.startsWith(this.buffer))) {
+ return "";
+ } else {
+ this.mode = "passthrough";
+ const content = this.buffer;
+ this.buffer = "";
+ return content;
+ }
+ }
+
+ if (this.buffer.length <= MAX_CLOSING_FENCE_LENGTH) return "";
+
+ const safeLength = this.buffer.length - MAX_CLOSING_FENCE_LENGTH;
+ const content = this.buffer.slice(0, safeLength);
+ this.buffer = this.buffer.slice(safeLength);
+ return content;
+ }
+
+ finish(): string {
+ if (this.mode === "detect") {
+ const content = this.buffer;
+ this.buffer = "";
+ return content;
+ }
+
+ if (this.mode === "passthrough") return "";
+
+ const content = this.buffer.replace(/\r?\n```[\t ]*(?:\r?\n)?$/, "");
+ this.buffer = "";
+ return content;
+ }
+}
diff --git a/packages/agno/src/prompt-openui.tsx b/packages/agno/src/prompt-openui.tsx
new file mode 100644
index 000000000..a295f2ea3
--- /dev/null
+++ b/packages/agno/src/prompt-openui.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import {
+ defineArtifactRenderer,
+ partialJSONParse,
+ useThread,
+ type ArtifactRendererConfig,
+ type CreateToolResult,
+} from "@openuidev/react-headless";
+import { BuiltinActionType, Renderer, type ActionEvent, type Library } from "@openuidev/react-lang";
+import { openuiChatLibrary } from "@openuidev/react-ui";
+import { useCallback, useMemo, useRef } from "react";
+
+export const AGNO_OPENUI_PROMPT_TOOL_NAME = "prompt_openui";
+
+export interface AgnoOpenUIActionResult {
+ type: BuiltinActionType.ContinueConversation;
+ message: string;
+ params: Record;
+ formState?: Record;
+ formName?: string;
+}
+
+interface OpenUIToolProps {
+ ui: string;
+ result?: AgnoOpenUIActionResult;
+}
+
+export interface CreateAgnoOpenUIPromptRendererOptions {
+ /** Exact library described to the AgentOS model. Defaults to openuiChatLibrary. */
+ library?: Library;
+ /** Backend external-execution tool name. Defaults to prompt_openui. */
+ toolName?: string;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function parseActionResult(value: unknown): AgnoOpenUIActionResult | undefined {
+ let parsed = value;
+ if (typeof value === "string") {
+ try {
+ parsed = JSON.parse(value);
+ } catch {
+ return undefined;
+ }
+ }
+ if (!isRecord(parsed) || typeof parsed["message"] !== "string") return undefined;
+
+ return {
+ type: BuiltinActionType.ContinueConversation,
+ message: parsed["message"],
+ params: isRecord(parsed["params"]) ? parsed["params"] : {},
+ ...(isRecord(parsed["formState"]) ? { formState: parsed["formState"] } : {}),
+ ...(typeof parsed["formName"] === "string" ? { formName: parsed["formName"] } : {}),
+ };
+}
+
+export function parseAgnoOpenUIPrompt(raw: {
+ args: unknown;
+ response: unknown;
+}): OpenUIToolProps | null {
+ const parsedArgs = typeof raw.args === "string" ? partialJSONParse(raw.args) : raw.args;
+ if (!isRecord(parsedArgs) || typeof parsedArgs["ui"] !== "string") return null;
+
+ const ui = parsedArgs["ui"];
+ if (ui.length === 0) return null;
+
+ const result = parseActionResult(raw.response);
+ return { ui, ...(result ? { result } : {}) };
+}
+
+function PromptOpenUI({
+ ui,
+ result,
+ library,
+ toolCallId,
+}: OpenUIToolProps & { library: Library; toolCallId?: string }) {
+ const isRunning = useThread((state) => state.isRunning);
+ const processToolResult = useThread((state) => state.processToolResult);
+ const completed = useRef(result !== undefined);
+
+ const initialState = useMemo(() => result?.formState, [result?.formState]);
+
+ const handleAction = useCallback(
+ (event: ActionEvent) => {
+ if (
+ !toolCallId ||
+ result !== undefined ||
+ completed.current ||
+ event.type !== BuiltinActionType.ContinueConversation
+ ) {
+ if (event.type === BuiltinActionType.OpenUrl) {
+ const url = event.params?.["url"];
+ if (typeof window !== "undefined" && typeof url === "string") {
+ window.open(url, "_blank", "noopener,noreferrer");
+ }
+ }
+ return;
+ }
+
+ completed.current = true;
+ const actionResult: AgnoOpenUIActionResult = {
+ type: BuiltinActionType.ContinueConversation,
+ message: event.humanFriendlyMessage,
+ params: event.params,
+ ...(event.formState !== undefined && { formState: event.formState }),
+ ...(event.formName !== undefined && { formName: event.formName }),
+ };
+ const toolResult: CreateToolResult = {
+ toolCallId,
+ content: JSON.stringify(actionResult),
+ };
+ void processToolResult(toolResult);
+ },
+ [processToolResult, result, toolCallId],
+ );
+
+ return (
+
+ );
+}
+
+/**
+ * Render Agno's external-execution prompt_openui tool inside AgentInterface.
+ * An @ToAssistant action becomes a trailing AG-UI ToolMessage, which resumes
+ * the paused AgentOS run in the same thread/session.
+ */
+export function createAgnoOpenUIPromptRenderer({
+ library = openuiChatLibrary,
+ toolName = AGNO_OPENUI_PROMPT_TOOL_NAME,
+}: CreateAgnoOpenUIPromptRendererOptions = {}): ArtifactRendererConfig {
+ return defineArtifactRenderer({
+ type: "openui_prompt",
+ toolName,
+ parser: (raw) => {
+ const props = parseAgnoOpenUIPrompt(raw);
+ return props ? { props, meta: null } : null;
+ },
+ preview: (props, controls) => (
+
+ ),
+ actual: () => null,
+ });
+}
+
+export const agnoOpenUIPromptRenderer = createAgnoOpenUIPromptRenderer();
diff --git a/packages/agno/src/storage.ts b/packages/agno/src/storage.ts
index 968e01739..dd73dd5b1 100644
--- a/packages/agno/src/storage.ts
+++ b/packages/agno/src/storage.ts
@@ -5,6 +5,7 @@ import type {
ToolCall,
UserMessage,
} from "@openuidev/react-headless";
+import { stripOpenUIFence } from "./openui-fence";
export type AgnoEntityType = "agent" | "team";
@@ -87,6 +88,10 @@ function contentToString(value: unknown): string {
}
}
+function stripAgnoAdditionalContext(content: string): string {
+ return content.replace(/\s*\s*[\s\S]*?\s*<\/additional context>\s*$/i, "");
+}
+
function messageTitle(message: UserMessage): string {
const content = message.content;
const text =
@@ -138,7 +143,7 @@ export function agnoHistoryToMessages(history: unknown, sessionId = "session"):
switch (entry["role"]) {
case "human":
case "user":
- return [{ id, role: "user", content }];
+ return [{ id, role: "user", content: stripAgnoAdditionalContext(content) }];
case "ai":
case "assistant": {
const toolCalls = normalizeToolCalls(entry["tool_calls"]);
@@ -146,7 +151,7 @@ export function agnoHistoryToMessages(history: unknown, sessionId = "session"):
{
id,
role: "assistant",
- content,
+ content: stripOpenUIFence(content),
...(toolCalls ? { toolCalls } : {}),
},
];
diff --git a/packages/agno/tsconfig.json b/packages/agno/tsconfig.json
index 8645f69b9..51f9c53a3 100644
--- a/packages/agno/tsconfig.json
+++ b/packages/agno/tsconfig.json
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"include": ["src/**/*"],
- "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"],
+ "exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "src/**/*.test.tsx"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
diff --git a/packages/react-headless/src/adapters/fetchLLM.ts b/packages/react-headless/src/adapters/fetchLLM.ts
index e5cbb45a8..8eac37ca3 100644
--- a/packages/react-headless/src/adapters/fetchLLM.ts
+++ b/packages/react-headless/src/adapters/fetchLLM.ts
@@ -15,6 +15,8 @@ export interface FetchLLMOptions {
fetch?: typeof fetch;
/** Extra fields merged into the request body (e.g. `model`) */
body?: Record;
+ /** AG-UI context entries sent with every run. */
+ context?: Array<{ description: string; value: string }>;
}
/**
@@ -29,6 +31,7 @@ export function fetchLLM({
headers,
fetch: customFetch,
body,
+ context = [],
}: FetchLLMOptions): ChatLLM {
const fetchImpl = customFetch ?? globalThis.fetch.bind(globalThis);
return {
@@ -43,7 +46,7 @@ export function fetchLLM({
body: JSON.stringify({
...body,
tools: [],
- context: [],
+ context,
threadId,
runId,
messages: messageFormat.toApi(messages),
diff --git a/packages/react-headless/src/hooks/useThread.ts b/packages/react-headless/src/hooks/useThread.ts
index 55c601250..5a39b90c4 100644
--- a/packages/react-headless/src/hooks/useThread.ts
+++ b/packages/react-headless/src/hooks/useThread.ts
@@ -19,6 +19,7 @@ const threadSelector = (s: ChatStore): ThreadSlice => ({
threadError: s.threadError,
executingToolCallIds: s.executingToolCallIds,
processMessage: s.processMessage,
+ processToolResult: s.processToolResult,
appendMessages: s.appendMessages,
updateMessage: s.updateMessage,
setMessages: s.setMessages,
diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts
index d8e5079e9..336edd003 100644
--- a/packages/react-headless/src/index.ts
+++ b/packages/react-headless/src/index.ts
@@ -76,6 +76,7 @@ export type {
ChatProviderProps,
ChatStore,
CreateMessage,
+ CreateToolResult,
Thread,
ThreadActions,
ThreadListActions,
diff --git a/packages/react-headless/src/store/__tests__/createChatStore.test.ts b/packages/react-headless/src/store/__tests__/createChatStore.test.ts
index 1d28dfbab..5e01e6148 100644
--- a/packages/react-headless/src/store/__tests__/createChatStore.test.ts
+++ b/packages/react-headless/src/store/__tests__/createChatStore.test.ts
@@ -355,6 +355,72 @@ describe("createChatStore", () => {
});
});
+ describe("processToolResult", () => {
+ it("appends a trailing tool message and continues the active thread", async () => {
+ const send = vi.fn().mockResolvedValue(new Response("", { status: 200 }));
+ const store = makeStore({
+ send,
+ streamProtocol: { parse: async function* () {} },
+ });
+ store.setState({
+ selectedThreadId: "t1",
+ messages: [
+ {
+ id: "assistant-1",
+ role: "assistant",
+ content: "",
+ toolCalls: [
+ {
+ id: "call-1",
+ type: "function",
+ function: { name: "prompt_openui", arguments: '{"ui":"root = Card([])"}' },
+ },
+ ],
+ },
+ ],
+ });
+
+ await store.getState().processToolResult({
+ toolCallId: "call-1",
+ content: '{"message":"Submitted"}',
+ });
+
+ expect(send).toHaveBeenCalledOnce();
+ expect(send.mock.calls[0]?.[0]).toMatchObject({
+ threadId: "t1",
+ messages: [
+ expect.objectContaining({ role: "assistant" }),
+ expect.objectContaining({
+ role: "tool",
+ toolCallId: "call-1",
+ content: '{"message":"Submitted"}',
+ }),
+ ],
+ });
+ expect(store.getState().messages.at(-1)).toMatchObject({
+ role: "tool",
+ toolCallId: "call-1",
+ });
+ expect(store.getState().isRunning).toBe(false);
+ });
+
+ it("rejects a tool result when there is no active thread", async () => {
+ const send = vi.fn();
+ const store = makeStore({ send });
+
+ await store.getState().processToolResult({
+ toolCallId: "call-1",
+ content: "submitted",
+ });
+
+ expect(send).not.toHaveBeenCalled();
+ expect(store.getState().messages).toEqual([]);
+ expect(store.getState().threadError?.message).toBe(
+ "Cannot submit a tool result without an active thread.",
+ );
+ });
+ });
+
// ────────────────────────────────────────────
// cancelMessage
// ────────────────────────────────────────────
diff --git a/packages/react-headless/src/store/artifactRendererTypes.ts b/packages/react-headless/src/store/artifactRendererTypes.ts
index 117e011de..ebd2701e9 100644
--- a/packages/react-headless/src/store/artifactRendererTypes.ts
+++ b/packages/react-headless/src/store/artifactRendererTypes.ts
@@ -8,6 +8,10 @@ import type { ReactNode } from "react";
export interface ArtifactRendererControls {
/** Whether this renderer's detailed view is the currently active one. */
isActive: boolean;
+ /** Tool call being rendered. Undefined when rendering a stored artifact. */
+ toolCallId?: string;
+ /** Tool name being rendered. Undefined when rendering a stored artifact. */
+ toolName?: string;
/**
* `true` while the tool call is still streaming — i.e. its arguments are
* arriving incrementally and no tool result has been paired in yet. Becomes
diff --git a/packages/react-headless/src/store/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts
index eed9fd96c..70a5ab1dc 100644
--- a/packages/react-headless/src/store/createChatStore.ts
+++ b/packages/react-headless/src/store/createChatStore.ts
@@ -4,6 +4,7 @@ import { subscribeWithSelector } from "zustand/middleware";
import { getResponseErrorMessage } from "../adapters/httpError";
import type { ChatLLM, ChatStorage } from "../adapters/types";
import { processStreamedMessage } from "../stream/processStreamedMessage";
+import type { ToolMessage } from "../types/message";
import { buildObservabilityErrorDetail, levelForStatus } from "./observability";
import type { ChatStore, Message, Thread, UserMessage } from "./types";
@@ -24,134 +25,17 @@ export const createChatStore = (configRef: React.RefObject()(
- subscribeWithSelector((set, get) => ({
- // Thread List State
- threads: [],
- isLoadingThreads: false,
- threadListError: null,
- selectedThreadId: null,
- hasMoreThreads: false,
- _nextCursor: undefined,
-
- // Thread State
- messages: [],
- isRunning: false,
- isLoadingMessages: false,
- threadError: null,
- executingToolCallIds: new Set(),
- _abortController: null,
-
- // ── Thread List Actions ──
-
- loadThreads: () => {
- set({ isLoadingThreads: true, threadListError: null });
- threadStorage
- .listThreads(undefined)
- .then(({ threads = [], nextCursor }) => {
- set({
- threads,
- isLoadingThreads: false,
- _nextCursor: nextCursor,
- hasMoreThreads: nextCursor !== undefined,
- });
- })
- .catch((e) => {
- set({ isLoadingThreads: false, threadListError: e });
- });
- },
-
- loadMoreThreads: () => {
- const cursor = get()._nextCursor;
- if (cursor === undefined) return;
- threadStorage
- .listThreads(cursor)
- .then(({ threads = [], nextCursor }) => {
- set((s) => ({
- threads: mergeThreadList(s.threads, threads),
- _nextCursor: nextCursor,
- hasMoreThreads: nextCursor !== undefined,
- }));
- })
- .catch((e) => {
- set({ threadListError: e });
- });
- },
-
- switchToNewThread: () => {
- get().cancelMessage();
- set({
- selectedThreadId: null,
- messages: [],
- threadError: null,
- executingToolCallIds: new Set(),
- });
- },
-
- createThread: async (firstMessage: UserMessage) => {
- const thread = await threadStorage.createThread(firstMessage);
- set((s) => ({ threads: mergeThreadList(s.threads, [thread]) }));
- return thread;
- },
-
- selectThread: (threadId: string) => {
- // Re-selecting the active thread is a no-op — don't wipe and refetch.
- if (get().selectedThreadId === threadId) return;
- get().cancelMessage();
- set({
- selectedThreadId: threadId,
- messages: [],
- isLoadingMessages: true,
- threadError: null,
- executingToolCallIds: new Set(),
- });
- threadStorage
- .getMessages(threadId)
- .then((messages) => set({ messages, isLoadingMessages: false }))
- .catch((e) => set({ threadError: e, isLoadingMessages: false }));
- },
-
- updateThread: (thread: Thread) => {
- const setPending = (id: string, isPending: boolean) =>
- set((s) => ({ threads: s.threads.map((t) => (t.id === id ? { ...t, isPending } : t)) }));
- setPending(thread.id, true);
- threadStorage
- .updateThread(thread)
- .then((updated) => {
- set((s) => ({
- threads: s.threads.map((t) => (t.id === updated.id ? updated : t)),
- }));
- })
- .catch(() => setPending(thread.id, false));
- },
-
- deleteThread: (threadId: string) => {
- const setPending = (id: string, isPending: boolean) =>
- set((s) => ({ threads: s.threads.map((t) => (t.id === id ? { ...t, isPending } : t)) }));
- setPending(threadId, true);
- threadStorage
- .deleteThread(threadId)
- .then(() => {
- const state = get();
- set({ threads: state.threads.filter((t) => t.id !== threadId) });
- if (state.selectedThreadId === threadId) {
- state.switchToNewThread();
- }
- })
- .catch(() => setPending(threadId, false));
- },
-
- // ── Thread Actions ──
-
- processMessage: async (message) => {
+ subscribeWithSelector((set, get) => {
+ const processInput = async (optimisticMessage: UserMessage | ToolMessage) => {
const state = get();
if (state.isRunning) return;
+ if (optimisticMessage.role === "tool" && !state.selectedThreadId) {
+ set({ threadError: new Error("Cannot submit a tool result without an active thread.") });
+ return;
+ }
+
const abortController = new AbortController();
- const optimisticMessage: UserMessage = {
- ...message,
- id: crypto.randomUUID(),
- role: "user",
- };
set({
_abortController: abortController,
@@ -169,7 +53,9 @@ export const createChatStore = (configRef: React.RefObject(),
});
}
- },
-
- appendMessages: (...newMessages: Message[]) => {
- set((s) => ({ messages: [...s.messages, ...newMessages] }));
- },
-
- updateMessage: (message: Message) => {
- set((s) => ({
- messages: s.messages.map((m) => (m.id === message.id ? message : m)),
- }));
- },
-
- setMessages: (messages: Message[]) => {
- set({ messages });
- },
-
- deleteMessage: (messageId: string) => {
- set((s) => ({ messages: s.messages.filter((m) => m.id !== messageId) }));
- },
-
- cancelMessage: () => {
- get()._abortController?.abort();
- },
- })),
+ };
+
+ return {
+ // Thread List State
+ threads: [],
+ isLoadingThreads: false,
+ threadListError: null,
+ selectedThreadId: null,
+ hasMoreThreads: false,
+ _nextCursor: undefined,
+
+ // Thread State
+ messages: [],
+ isRunning: false,
+ isLoadingMessages: false,
+ threadError: null,
+ executingToolCallIds: new Set(),
+ _abortController: null,
+
+ // ── Thread List Actions ──
+
+ loadThreads: () => {
+ set({ isLoadingThreads: true, threadListError: null });
+ threadStorage
+ .listThreads(undefined)
+ .then(({ threads = [], nextCursor }) => {
+ set({
+ threads,
+ isLoadingThreads: false,
+ _nextCursor: nextCursor,
+ hasMoreThreads: nextCursor !== undefined,
+ });
+ })
+ .catch((e) => {
+ set({ isLoadingThreads: false, threadListError: e });
+ });
+ },
+
+ loadMoreThreads: () => {
+ const cursor = get()._nextCursor;
+ if (cursor === undefined) return;
+ threadStorage
+ .listThreads(cursor)
+ .then(({ threads = [], nextCursor }) => {
+ set((s) => ({
+ threads: mergeThreadList(s.threads, threads),
+ _nextCursor: nextCursor,
+ hasMoreThreads: nextCursor !== undefined,
+ }));
+ })
+ .catch((e) => {
+ set({ threadListError: e });
+ });
+ },
+
+ switchToNewThread: () => {
+ get().cancelMessage();
+ set({
+ selectedThreadId: null,
+ messages: [],
+ threadError: null,
+ executingToolCallIds: new Set(),
+ });
+ },
+
+ createThread: async (firstMessage: UserMessage) => {
+ const thread = await threadStorage.createThread(firstMessage);
+ set((s) => ({ threads: mergeThreadList(s.threads, [thread]) }));
+ return thread;
+ },
+
+ selectThread: (threadId: string) => {
+ // Re-selecting the active thread is a no-op — don't wipe and refetch.
+ if (get().selectedThreadId === threadId) return;
+ get().cancelMessage();
+ set({
+ selectedThreadId: threadId,
+ messages: [],
+ isLoadingMessages: true,
+ threadError: null,
+ executingToolCallIds: new Set(),
+ });
+ threadStorage
+ .getMessages(threadId)
+ .then((messages) => set({ messages, isLoadingMessages: false }))
+ .catch((e) => set({ threadError: e, isLoadingMessages: false }));
+ },
+
+ updateThread: (thread: Thread) => {
+ const setPending = (id: string, isPending: boolean) =>
+ set((s) => ({
+ threads: s.threads.map((t) => (t.id === id ? { ...t, isPending } : t)),
+ }));
+ setPending(thread.id, true);
+ threadStorage
+ .updateThread(thread)
+ .then((updated) => {
+ set((s) => ({
+ threads: s.threads.map((t) => (t.id === updated.id ? updated : t)),
+ }));
+ })
+ .catch(() => setPending(thread.id, false));
+ },
+
+ deleteThread: (threadId: string) => {
+ const setPending = (id: string, isPending: boolean) =>
+ set((s) => ({
+ threads: s.threads.map((t) => (t.id === id ? { ...t, isPending } : t)),
+ }));
+ setPending(threadId, true);
+ threadStorage
+ .deleteThread(threadId)
+ .then(() => {
+ const state = get();
+ set({ threads: state.threads.filter((t) => t.id !== threadId) });
+ if (state.selectedThreadId === threadId) {
+ state.switchToNewThread();
+ }
+ })
+ .catch(() => setPending(threadId, false));
+ },
+
+ // ── Thread Actions ──
+
+ processMessage: (message) =>
+ processInput({
+ ...message,
+ id: crypto.randomUUID(),
+ role: "user",
+ }),
+
+ processToolResult: (result) =>
+ processInput({
+ ...result,
+ id: crypto.randomUUID(),
+ role: "tool",
+ }),
+
+ appendMessages: (...newMessages: Message[]) => {
+ set((s) => ({ messages: [...s.messages, ...newMessages] }));
+ },
+
+ updateMessage: (message: Message) => {
+ set((s) => ({
+ messages: s.messages.map((m) => (m.id === message.id ? message : m)),
+ }));
+ },
+
+ setMessages: (messages: Message[]) => {
+ set({ messages });
+ },
+
+ deleteMessage: (messageId: string) => {
+ set((s) => ({ messages: s.messages.filter((m) => m.id !== messageId) }));
+ },
+
+ cancelMessage: () => {
+ get()._abortController?.abort();
+ },
+ };
+ }),
);
return store;
diff --git a/packages/react-headless/src/store/types.ts b/packages/react-headless/src/store/types.ts
index 8f673ecad..6b8aef36c 100644
--- a/packages/react-headless/src/store/types.ts
+++ b/packages/react-headless/src/store/types.ts
@@ -1,9 +1,10 @@
import type { ArtifactCategory, ChatLLM, ChatStorage } from "../adapters/types";
-import type { Message, UserMessage } from "../types/message";
+import type { Message, ToolMessage, UserMessage } from "../types/message";
import type { ArtifactRendererConfig } from "./artifactRendererTypes";
export type { Message, UserMessage } from "../types/message";
export type CreateMessage = Omit;
+export type CreateToolResult = Omit;
export type Thread = {
id: string;
@@ -52,6 +53,8 @@ export type ThreadState = {
export type ThreadActions = {
processMessage: (message: CreateMessage) => Promise;
+ /** Append a frontend tool result and continue the same model run. */
+ processToolResult: (result: CreateToolResult) => Promise;
appendMessages: (...messages: Message[]) => void;
updateMessage: (message: Message) => void;
setMessages: (messages: Message[]) => void;
diff --git a/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx b/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx
index 778d278a1..61d15c050 100644
--- a/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx
+++ b/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx
@@ -153,6 +153,8 @@ export function ToolActivityRenderer({
const controls: ArtifactRendererControls = {
isActive,
isStreaming,
+ toolCallId: activity.toolCall.id,
+ toolName: activity.toolName,
open,
close,
toggle,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 167060393..bb88a6810 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1365,6 +1365,18 @@ importers:
'@openuidev/react-headless':
specifier: workspace:^
version: link:../react-headless
+ '@openuidev/react-lang':
+ specifier: workspace:^
+ version: link:../react-lang
+ '@openuidev/react-ui':
+ specifier: workspace:^
+ version: link:../react-ui
+ '@types/react':
+ specifier: 'catalog:'
+ version: 19.2.17
+ react:
+ specifier: 'catalog:'
+ version: 19.2.4
typescript:
specifier: 'catalog:'
version: 5.9.3