From 91fe1cd88347e91e9e4d9ae7ad6eecba107c1880 Mon Sep 17 00:00:00 2001
From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com>
Date: Sun, 30 Aug 2026 15:11:44 +0530
Subject: [PATCH] feat(packages): add Langflow adapter
---
.github/workflows/publish-npm-package.yml | 1 +
README.md | 5 +
docs/app/(home)/integrations/data.ts | 22 +-
docs/content/docs/api-reference/index.mdx | 7 +
docs/content/docs/api-reference/langflow.mdx | 220 ++++++++++++++++++
docs/content/docs/api-reference/meta.json | 1 +
packages/langflow/README.md | 157 +++++++++++++
packages/langflow/eslint.config.cjs | 73 ++++++
packages/langflow/package.json | 69 ++++++
packages/langflow/src/__tests__/input.test.ts | 70 ++++++
.../src/__tests__/request-handler.test.ts | 111 +++++++++
.../langflow/src/__tests__/workflow.test.ts | 81 +++++++
packages/langflow/src/id.ts | 18 ++
packages/langflow/src/index.ts | 13 ++
packages/langflow/src/input.ts | 66 ++++++
packages/langflow/src/request-handler.ts | 143 ++++++++++++
packages/langflow/src/workflow.ts | 129 ++++++++++
packages/langflow/tsconfig.json | 13 ++
packages/langflow/tsconfig.test.json | 9 +
packages/langflow/tsdown.config.ts | 14 ++
pnpm-lock.yaml | 10 +
21 files changed, 1231 insertions(+), 1 deletion(-)
create mode 100644 docs/content/docs/api-reference/langflow.mdx
create mode 100644 packages/langflow/README.md
create mode 100644 packages/langflow/eslint.config.cjs
create mode 100644 packages/langflow/package.json
create mode 100644 packages/langflow/src/__tests__/input.test.ts
create mode 100644 packages/langflow/src/__tests__/request-handler.test.ts
create mode 100644 packages/langflow/src/__tests__/workflow.test.ts
create mode 100644 packages/langflow/src/id.ts
create mode 100644 packages/langflow/src/index.ts
create mode 100644 packages/langflow/src/input.ts
create mode 100644 packages/langflow/src/request-handler.ts
create mode 100644 packages/langflow/src/workflow.ts
create mode 100644 packages/langflow/tsconfig.json
create mode 100644 packages/langflow/tsconfig.test.json
create mode 100644 packages/langflow/tsdown.config.ts
diff --git a/.github/workflows/publish-npm-package.yml b/.github/workflows/publish-npm-package.yml
index bce42ebd9..b4e6508ee 100644
--- a/.github/workflows/publish-npm-package.yml
+++ b/.github/workflows/publish-npm-package.yml
@@ -19,6 +19,7 @@ on:
- react-email
- lang-core
- langchain
+ - langflow
- openui-cli
- svelte-lang
- vue-lang
diff --git a/README.md b/README.md
index 0a5bcd439..f0b329bc2 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/langflow`](./packages/langflow) | Langflow workflows | Server helpers that map OpenUI turns to Workflow API v2 and relay Langflow's native AG-UI stream |
| [`@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
+# Langflow Workflow API integration
+npm install @openuidev/langflow
+
# 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
+│ ├── langflow/ # Langflow Workflow API and AG-UI 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/docs/app/(home)/integrations/data.ts b/docs/app/(home)/integrations/data.ts
index 1f51abf6a..3ff3c51fa 100644
--- a/docs/app/(home)/integrations/data.ts
+++ b/docs/app/(home)/integrations/data.ts
@@ -86,7 +86,11 @@ const integrationCatalog: Integration[] = [
howItWorks:
"Each shadcn component is registered with defineComponent and a Zod prop schema. createLibrary produces both the prompt vocabulary and the renderer mapping used by the example chat app.",
links: [
- { label: "Integration guide", href: "/docs/openui-lang/examples/design-systems/shadcn", kind: "Guide" },
+ {
+ label: "Integration guide",
+ href: "/docs/openui-lang/examples/design-systems/shadcn",
+ kind: "Guide",
+ },
exampleLink("shadcn-chat"),
{ label: "shadcn/ui", href: "https://ui.shadcn.com", kind: "Website" },
],
@@ -166,6 +170,22 @@ const integrationCatalog: Integration[] = [
exampleLink("langchain-chat"),
],
},
+ {
+ slug: "langflow",
+ name: "Langflow",
+ logo: "https://raw.githubusercontent.com/langflow-ai/langflow/main/docs/static/img/langflow-icon-black-transparent.svg",
+ category: "ai-frameworks",
+ type: "Visual agent framework adapter",
+ summary:
+ "Connect Langflow workflows to OpenUI through Workflow API v2 and Langflow's native AG-UI stream.",
+ howItWorks:
+ "The @openuidev/langflow package maps OpenUI thread, follow-up, and form turns to Langflow workflow inputs and session ids. Langflow runs the flow and emits native AG-UI SSE, which agUIAdapter() consumes in AgentInterface.",
+ install: "npm install @openuidev/langflow",
+ links: [
+ ...packageLinks("@openuidev/langflow", "langflow", "/docs/api-reference/langflow"),
+ { label: "Langflow", href: "https://www.langflow.org", kind: "Website" },
+ ],
+ },
{
slug: "vercel-ai-sdk",
name: "Vercel AI SDK",
diff --git a/docs/content/docs/api-reference/index.mdx b/docs/content/docs/api-reference/index.mdx
index 9096c6513..28b787b3e 100644
--- a/docs/content/docs/api-reference/index.mdx
+++ b/docs/content/docs/api-reference/index.mdx
@@ -17,6 +17,8 @@ The OpenUI SDK is split into packages that build on each other:
- **`@openuidev/langchain`** — LangChain and LangGraph integration primitives for transforming protocol-v2 agent streams into AG-UI events and relaying them to an OpenUI frontend over Web-standard Server-Sent Events.
+- **`@openuidev/langflow`** — Server-side helpers that map OpenUI chat turns to Langflow Workflow API v2 runs and relay Langflow's native AG-UI stream.
+
- **`@openuidev/react-email`** — Pre-built email component library and prompt options for model-generated emails that can be rendered to HTML with React Email.
- **`@openuidev/vue-lang`** — Vue 3 bindings for defining model-renderable components, generating prompts from those definitions, and rendering streamed OpenUI Lang in Vue apps.
@@ -39,6 +41,7 @@ The OpenUI SDK is split into packages that build on each other:
| A ready-made React chat surface | [`@openuidev/react-ui`](/docs/api-reference/react-ui) |
| OpenUI tool rendering in an assistant-ui application | [`@openuidev/assistant-ui`](/docs/api-reference/assistant-ui) |
| A LangChain or LangGraph agent connected to an OpenUI frontend | [`@openuidev/langchain`](/docs/api-reference/langchain) |
+| A Langflow workflow connected to an OpenUI Agent Interface | [`@openuidev/langflow`](/docs/api-reference/langflow) |
| Model-generated emails | [`@openuidev/react-email`](/docs/api-reference/react-email) |
| Vue integration | [`@openuidev/vue-lang`](https://github.com/thesysdev/openui/tree/main/packages/vue-lang) |
| Svelte integration | [`@openuidev/svelte-lang`](https://github.com/thesysdev/openui/tree/main/packages/svelte-lang) |
@@ -75,6 +78,10 @@ The OpenUI SDK is split into packages that build on each other:
LangGraph stream transformation, stateless run helpers, and AG-UI SSE relay APIs for OpenUI
frontends.
+
+ Workflow API v2 request mapping, session scoping, action normalization, and native AG-UI
+ passthrough for OpenUI frontends.
+
API reference for the pre-built email templates library and prompt options.
diff --git a/docs/content/docs/api-reference/langflow.mdx b/docs/content/docs/api-reference/langflow.mdx
new file mode 100644
index 000000000..e535b9864
--- /dev/null
+++ b/docs/content/docs/api-reference/langflow.mdx
@@ -0,0 +1,220 @@
+---
+title: "@openuidev/langflow"
+description: API reference for streaming Langflow Workflow API v2 runs to OpenUI over native AG-UI.
+---
+
+Use this package to connect an OpenUI Agent Interface to a Langflow workflow. It maps OpenUI
+messages, thread ids, follow-ups, and submitted form values to Langflow's Workflow API v2 and
+returns Langflow's native AG-UI stream without an intermediate protocol conversion.
+
+The package is framework-independent at the HTTP boundary. Its APIs use Web-standard `Request`,
+`Response`, `fetch`, and `ReadableStream` types.
+
+## Install
+
+```bash tab="pnpm" tab-group="pkg"
+pnpm add @openuidev/langflow
+```
+
+```bash tab="bun" tab-group="pkg"
+bun add @openuidev/langflow
+```
+
+```bash tab="yarn" tab-group="pkg"
+yarn add @openuidev/langflow
+```
+
+```bash tab="npm" tab-group="pkg"
+npm install @openuidev/langflow
+```
+
+The integration requires a Langflow server that exposes `POST /api/v2/workflows` and supports
+`stream_protocol: "agui"`. It was tested with Langflow/LFX 1.11.4.
+
+## Data flow
+
+1. OpenUI's `fetchLLM` sends `{ threadId, messages }` to an application server route.
+2. `createLangflowStreamResponse()` extracts the newest user or action turn, maps `threadId` to
+ Langflow's `session_id`, and starts a Workflow API v2 streaming run.
+3. Langflow runs the flow and returns native AG-UI Server-Sent Events.
+4. OpenUI's `agUIAdapter()` consumes the response in `AgentInterface`.
+
+Langflow owns the workflow graph, model calls, tools, and session memory. OpenUI owns the chat
+surface, OpenUI Lang parser and renderer, streaming UI state, theme, follow-ups, and form actions.
+
+## `createLangflowStreamResponse(request, options)`
+
+Creates the complete OpenUI-request-to-Langflow-response path:
+
+```ts
+function createLangflowStreamResponse(
+ request: Request,
+ options: CreateLangflowStreamResponseOptions,
+): Promise;
+```
+
+```ts
+interface CreateLangflowStreamResponseOptions extends LangflowWorkflowOverrides {
+ apiUrl: string;
+ flowId: string;
+ apiKey?: string;
+ headers?: Record;
+ prepareSessionId?: (context: PrepareLangflowSessionContext) => string | Promise;
+ prepareInput?: (context: PrepareLangflowInputContext) => string | Promise;
+ debug?: boolean;
+ fetch?: typeof fetch;
+}
+```
+
+The request body must contain a non-empty AG-UI `messages` array. A missing `threadId` receives an
+opaque generated id. Invalid requests return JSON with status `400` without starting a Langflow
+run. Upstream failures return status `502`; response details remain redacted unless trusted
+development code enables `debug`.
+
+The helper can be returned directly from a Next.js route or another Web-standard route handler:
+
+```ts
+import { createLangflowStreamResponse } from "@openuidev/langflow";
+
+export const runtime = "nodejs";
+
+export async function POST(request: Request) {
+ return createLangflowStreamResponse(request, {
+ apiUrl: process.env.LANGFLOW_API_URL ?? "http://localhost:7860",
+ apiKey: process.env.LANGFLOW_API_KEY,
+ flowId: process.env.LANGFLOW_FLOW_ID!,
+ debug: process.env.NODE_ENV !== "production",
+ });
+}
+```
+
+Keep the Langflow API key in the server route. Do not expose it to the browser.
+
+### `prepareSessionId(context)`
+
+OpenUI's `threadId` is client input. Authenticate the route and use `prepareSessionId` to authorize
+or scope Langflow session ids in a multi-user application:
+
+```ts
+const user = await requireUser(request);
+
+return createLangflowStreamResponse(request, {
+ apiUrl: process.env.LANGFLOW_API_URL!,
+ apiKey: process.env.LANGFLOW_API_KEY,
+ flowId: process.env.LANGFLOW_FLOW_ID!,
+ prepareSessionId: ({ threadId }) => `${user.id}:${threadId}`,
+});
+```
+
+```ts
+interface PrepareLangflowSessionContext {
+ threadId: string;
+ requestBody: Record;
+}
+```
+
+### `prepareInput(context)`
+
+By default, the helper sends only the newest non-empty user turn because Langflow uses
+`session_id` for conversation memory. OpenUI follow-ups and forms encode a readable action plus
+structured context in that turn. The helper removes the transport markers while preserving the
+action label and edited form values.
+
+Use `prepareInput` to add trusted application context after authenticating the request:
+
+```ts
+return createLangflowStreamResponse(request, {
+ apiUrl: process.env.LANGFLOW_API_URL!,
+ apiKey: process.env.LANGFLOW_API_KEY,
+ flowId: process.env.LANGFLOW_FLOW_ID!,
+ prepareInput: ({ inputValue, requestBody }) =>
+ `[tenant=${String(requestBody.tenant)}] ${inputValue}`,
+});
+```
+
+```ts
+interface PrepareLangflowInputContext {
+ messages: Message[];
+ inputValue: string;
+ requestBody: Record;
+ sessionId: string;
+}
+```
+
+Treat all fields in `requestBody` as untrusted client input.
+
+## `streamLangflowWorkflow(options)`
+
+Use the lower-level helper when the application already owns request parsing:
+
+```ts
+interface StreamLangflowWorkflowOptions extends LangflowWorkflowOverrides {
+ apiUrl: string;
+ flowId: string;
+ inputValue: string;
+ sessionId: string;
+ apiKey?: string;
+ headers?: Record;
+ signal?: AbortSignal;
+ debug?: boolean;
+ fetch?: typeof fetch;
+}
+
+function streamLangflowWorkflow(options: StreamLangflowWorkflowOptions): Promise;
+```
+
+```ts
+import { streamLangflowWorkflow } from "@openuidev/langflow";
+
+const response = await streamLangflowWorkflow({
+ apiUrl: "http://localhost:7860",
+ apiKey: process.env.LANGFLOW_API_KEY,
+ flowId: process.env.LANGFLOW_FLOW_ID!,
+ inputValue: "Show quarterly revenue as a labeled bar chart",
+ sessionId: "thread-123",
+ signal: request.signal,
+});
+```
+
+The helper fixes `mode` to `"stream"` and `stream_protocol` to `"agui"`. It passes the upstream
+response body through as AG-UI SSE. `LangflowWorkflowOverrides` supports `tweaks`, live-canvas
+`data`, uploaded `files`, and partial-run `startComponentId` and `stopComponentId` values.
+
+## Connect the OpenUI frontend
+
+Langflow already emits AG-UI, so the browser uses OpenUI's maintained adapter:
+
+```tsx
+"use client";
+
+import { AgentInterface, agUIAdapter, fetchLLM } from "@openuidev/react-ui";
+
+const llm = fetchLLM({
+ url: "/api/chat",
+ streamAdapter: agUIAdapter(),
+});
+
+export function Chat() {
+ return ;
+}
+```
+
+Keep `AgentInterface`'s theme provider enabled. Attach the system prompt generated from the same
+OpenUI component library rendered by the frontend to the Langflow Agent component.
+
+## Exports
+
+| Export | Description |
+| :------------------------------------ | :---------------------------------------------------------- |
+| `createLangflowStreamResponse` | Complete Web-standard request/response helper |
+| `streamLangflowWorkflow` | Lower-level Workflow API v2 streaming call |
+| `toLangflowInput` | Newest-turn and OpenUI action/form normalizer |
+| `LangflowRequestError` | Upstream HTTP error with optional development detail |
+| `CreateLangflowStreamResponseOptions` | Request helper configuration |
+| `PrepareLangflowInputContext` | Values passed to `prepareInput` |
+| `PrepareLangflowSessionContext` | Values passed to `prepareSessionId` |
+| `StreamLangflowWorkflowOptions` | Lower-level streaming configuration |
+| `LangflowWorkflowOverrides` | Workflow tweaks, data, files, and partial-run component ids |
+
+See Langflow's [Workflow API documentation](https://docs.langflow.org/workflow-api) for the
+upstream endpoint and flow configuration.
diff --git a/docs/content/docs/api-reference/meta.json b/docs/content/docs/api-reference/meta.json
index 2ad690e79..cb4d3db13 100644
--- a/docs/content/docs/api-reference/meta.json
+++ b/docs/content/docs/api-reference/meta.json
@@ -10,6 +10,7 @@
"cli",
"devtools",
"langchain",
+ "langflow",
"assistant-ui"
]
}
diff --git a/packages/langflow/README.md b/packages/langflow/README.md
new file mode 100644
index 000000000..8e799756d
--- /dev/null
+++ b/packages/langflow/README.md
@@ -0,0 +1,157 @@
+# `@openuidev/langflow`
+
+Server-side integration primitives for connecting an
+[OpenUI](https://www.openui.com/) Agent Interface to a
+[Langflow](https://github.com/langflow-ai/langflow) workflow.
+
+The package uses Langflow's public Workflow API v2 and its native AG-UI stream:
+
+```text
+OpenUI AgentInterface
+ -> your server route
+ -> @openuidev/langflow
+ -> POST Langflow /api/v2/workflows (stream_protocol: "agui")
+ -> Langflow flow, model, tools, and session memory
+ -> native AG-UI SSE
+ -> OpenUI agUIAdapter(), parser, and renderer
+```
+
+Langflow owns the workflow graph, model calls, tools, and session memory. OpenUI
+owns the chat shell, OpenUI Lang parser and renderer, streaming UI state, theme,
+follow-ups, and form actions. The adapter maps the OpenUI `threadId` to Langflow's
+`session_id` and turns the latest user/action turn into `input_value`.
+
+## Install
+
+```bash
+npm install @openuidev/langflow @openuidev/react-ui
+```
+
+The package requires a Langflow server that exposes `POST /api/v2/workflows` and
+supports `stream_protocol: "agui"`. It was tested with Langflow/LFX 1.11.4.
+
+## Add a server route
+
+`createLangflowStreamResponse` accepts and returns Web-standard request types, so
+it works in Next.js, Remix, Hono, and other compatible server runtimes:
+
+```ts
+import { createLangflowStreamResponse } from "@openuidev/langflow";
+
+export const runtime = "nodejs";
+
+export async function POST(request: Request) {
+ return createLangflowStreamResponse(request, {
+ apiUrl: process.env.LANGFLOW_API_URL ?? "http://localhost:7860",
+ apiKey: process.env.LANGFLOW_API_KEY,
+ flowId: process.env.LANGFLOW_FLOW_ID!,
+ debug: process.env.NODE_ENV !== "production",
+ });
+}
+```
+
+The route expects the standard body posted by OpenUI's `fetchLLM`, including
+`{ threadId, messages }`. Credentials remain in the server route and are never
+sent to the browser.
+
+`threadId` is client input. In a multi-user application, authenticate the route
+and use `prepareSessionId` to authorize or scope it before it reaches Langflow:
+
+```ts
+const user = await requireUser(request);
+
+return createLangflowStreamResponse(request, {
+ apiUrl: process.env.LANGFLOW_API_URL!,
+ apiKey: process.env.LANGFLOW_API_KEY,
+ flowId: process.env.LANGFLOW_FLOW_ID!,
+ prepareSessionId: ({ threadId }) => `${user.id}:${threadId}`,
+});
+```
+
+OpenUI follow-ups and form submissions encode a human-readable action plus action
+context in the newest user message. The helper removes the transport markers and
+preserves the action label and edited form state in Langflow's `input_value`.
+
+Use `prepareInput` to add application context without trusting client fields as
+Langflow configuration:
+
+```ts
+return createLangflowStreamResponse(request, {
+ apiUrl: process.env.LANGFLOW_API_URL!,
+ apiKey: process.env.LANGFLOW_API_KEY,
+ flowId: process.env.LANGFLOW_FLOW_ID!,
+ prepareInput: ({ inputValue, requestBody }) =>
+ `[tenant=${String(requestBody.tenant)}] ${inputValue}`,
+});
+```
+
+## Connect Agent Interface
+
+Langflow already emits AG-UI, so use OpenUI's maintained `agUIAdapter()` directly:
+
+```tsx
+"use client";
+
+import { AgentInterface, agUIAdapter, fetchLLM } from "@openuidev/react-ui";
+
+const llm = fetchLLM({
+ url: "/api/chat",
+ streamAdapter: agUIAdapter(),
+});
+
+export function Chat() {
+ return ;
+}
+```
+
+Keep `AgentInterface`'s theme provider enabled, and render with the same OpenUI
+component library whose generated system prompt is attached to the Langflow
+Agent component.
+
+## Configure the Langflow flow
+
+A minimal chat flow is:
+
+```text
+Chat Input -> Agent -> Chat Output
+```
+
+Set the Agent's system prompt to the prompt generated from the exact
+`openuiChatLibrary` version rendered by the frontend. The flow must stream the
+model's OpenUI Lang text rather than wrapping it in Markdown.
+
+## Lower-level workflow API
+
+Use `streamLangflowWorkflow` when the application already owns request parsing:
+
+```ts
+import { streamLangflowWorkflow } from "@openuidev/langflow";
+
+const response = await streamLangflowWorkflow({
+ apiUrl: "http://localhost:7860",
+ apiKey: process.env.LANGFLOW_API_KEY,
+ flowId: process.env.LANGFLOW_FLOW_ID!,
+ inputValue: "Show quarterly revenue as a labeled bar chart",
+ sessionId: "thread-123",
+ signal: request.signal,
+});
+```
+
+The helper fixes `mode` to `"stream"` and `stream_protocol` to `"agui"`. It also
+supports Langflow `tweaks`, live-canvas `data`, uploaded `files`, and partial-run
+component ids.
+
+## API
+
+- `createLangflowStreamResponse(request, options)` - complete Agent Interface to
+ Langflow route helper.
+- `streamLangflowWorkflow(options)` - lower-level Workflow API v2 streaming call.
+- `toLangflowInput(messages)` - pure newest-turn and OpenUI action/form normalizer.
+- `LangflowRequestError` - upstream HTTP failure with optional development detail.
+- `CreateLangflowStreamResponseOptions`, `PrepareLangflowInputContext`,
+ `PrepareLangflowSessionContext`, `StreamLangflowWorkflowOptions`, and
+ `LangflowWorkflowOverrides` - exported TypeScript types.
+
+See the
+[`@openuidev/langflow` API reference](https://www.openui.com/docs/api-reference/langflow)
+for the complete option and export reference.
diff --git a/packages/langflow/eslint.config.cjs b/packages/langflow/eslint.config.cjs
new file mode 100644
index 000000000..413c49627
--- /dev/null
+++ b/packages/langflow/eslint.config.cjs
@@ -0,0 +1,73 @@
+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: [
+ "**/*.stories.tsx",
+ "**/__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: "^_",
+ },
+ ],
+ "@typescript-eslint/no-use-before-define": [
+ "error",
+ {
+ functions: false,
+ classes: false,
+ variables: false,
+ },
+ ],
+ "unused-imports/no-unused-imports": "error",
+ "no-console": [
+ "error",
+ {
+ allow: ["error", "warn", "info"],
+ },
+ ],
+ ...eslintPluginPrettier.configs.recommended.rules,
+ },
+ },
+ prettier,
+];
diff --git a/packages/langflow/package.json b/packages/langflow/package.json
new file mode 100644
index 000000000..702276547
--- /dev/null
+++ b/packages/langflow/package.json
@@ -0,0 +1,69 @@
+{
+ "name": "@openuidev/langflow",
+ "version": "0.0.1",
+ "description": "Langflow Workflow API integration for streaming OpenUI generative interfaces over AG-UI",
+ "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",
+ "langflow",
+ "ag-ui",
+ "generative-ui",
+ "streaming",
+ "sse"
+ ],
+ "homepage": "https://openui.com",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/thesysdev/openui.git",
+ "directory": "packages/langflow"
+ },
+ "bugs": {
+ "url": "https://github.com/thesysdev/openui/issues"
+ },
+ "author": "engineering@thesys.dev",
+ "dependencies": {
+ "@ag-ui/core": "^0.0.57"
+ },
+ "devDependencies": {
+ "vitest": "^4.1.0"
+ }
+}
diff --git a/packages/langflow/src/__tests__/input.test.ts b/packages/langflow/src/__tests__/input.test.ts
new file mode 100644
index 000000000..82c64cbbb
--- /dev/null
+++ b/packages/langflow/src/__tests__/input.test.ts
@@ -0,0 +1,70 @@
+import type { Message } from "@ag-ui/core";
+import { describe, expect, it } from "vitest";
+
+import { toLangflowInput } from "../input";
+
+describe("toLangflowInput", () => {
+ it("sends only the newest user turn because the thread maps to Langflow memory", () => {
+ const input = toLangflowInput([
+ userMessage("user-1", "Compare Python and Rust adoption."),
+ assistantMessage("assistant-1", "root = Card([])"),
+ userMessage("user-2", "Now show the five-year trend."),
+ ]);
+
+ expect(input).toBe("Now show the five-year trend.");
+ });
+
+ it("keeps a clicked follow-up and its action context", () => {
+ const input = toLangflowInput([
+ userMessage(
+ "user-1",
+ ']]\u003eopenui:content\nShow the five-year trend\n]]\u003eopenui:context\n["User clicked: Show the five-year trend"]',
+ ),
+ ]);
+
+ expect(input).toBe("Show the five-year trend\nUser clicked: Show the five-year trend");
+ });
+
+ it("passes edited form values and action context to Langflow", () => {
+ const input = toLangflowInput([
+ userMessage(
+ "user-1",
+ ']]\u003eopenui:content\nSubmit project\n]]\u003eopenui:context\n["User clicked: Submit project",{"name":"Ada Lovelace","email":"ada@example.com","teamSize":"11-25"}]',
+ ),
+ ]);
+
+ expect(input).toContain("Submit project");
+ expect(input).toContain("Ada Lovelace");
+ expect(input).toContain("ada@example.com");
+ expect(input).toContain('"teamSize":"11-25"');
+ });
+
+ it("reads text blocks from multimodal user content", () => {
+ const input = toLangflowInput([
+ {
+ id: "user-1",
+ role: "user",
+ content: [
+ { type: "text", text: "Describe this file" },
+ { type: "binary", mimeType: "image/png", data: "aGVsbG8=" },
+ ],
+ },
+ ]);
+
+ expect(input).toBe("Describe this file");
+ });
+
+ it("rejects a request without a non-empty user turn", () => {
+ expect(() => toLangflowInput([assistantMessage("assistant-1", "Hello")])).toThrow(
+ "Expected at least one non-empty user message",
+ );
+ });
+});
+
+function userMessage(id: string, content: string): Message {
+ return { id, role: "user", content };
+}
+
+function assistantMessage(id: string, content: string): Message {
+ return { id, role: "assistant", content };
+}
diff --git a/packages/langflow/src/__tests__/request-handler.test.ts b/packages/langflow/src/__tests__/request-handler.test.ts
new file mode 100644
index 000000000..e928b26d1
--- /dev/null
+++ b/packages/langflow/src/__tests__/request-handler.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createLangflowStreamResponse } from "../request-handler";
+
+describe("createLangflowStreamResponse", () => {
+ it("maps an OpenUI thread and latest action turn to Langflow", async () => {
+ const nativeStream = [
+ 'data: {"type":"RUN_STARTED","threadId":"thread-7","runId":"run-1"}',
+ 'data: {"type":"TEXT_MESSAGE_START","messageId":"message-1","role":"assistant"}',
+ 'data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"message-1","delta":"root = Card([])"}',
+ 'data: {"type":"TEXT_MESSAGE_END","messageId":"message-1"}',
+ 'data: {"type":"RUN_FINISHED","threadId":"thread-7","runId":"run-1"}',
+ "",
+ ].join("\n\n");
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(nativeStream, { headers: { "Content-Type": "text/event-stream" } }),
+ );
+ const request = chatRequest({
+ threadId: "thread-7",
+ messages: [
+ { id: "user-1", role: "user", content: "Build an intake form" },
+ { id: "assistant-1", role: "assistant", content: "root = Card([])" },
+ {
+ id: "user-2",
+ role: "user",
+ content:
+ ']]\u003eopenui:content\nSubmit project\n]]\u003eopenui:context\n["User clicked: Submit project",{"project":"Aurora-731","teamSize":7}]',
+ },
+ ],
+ });
+
+ const response = await createLangflowStreamResponse(request, {
+ apiUrl: "http://localhost:7860",
+ apiKey: "secret",
+ flowId: "flow-1",
+ fetch: fetchMock,
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe(nativeStream);
+ const payload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
+ expect(payload.session_id).toBe("thread-7");
+ expect(payload.stream_protocol).toBe("agui");
+ expect(payload.input_value).toContain("Aurora-731");
+ expect(payload.input_value).not.toContain("Build an intake form");
+ });
+
+ it("supports application-specific input preparation", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(new Response("data: {}\n\n"));
+ const request = chatRequest({
+ threadId: "thread-1",
+ tenant: "acme",
+ messages: [{ id: "user-1", role: "user", content: "Hello" }],
+ });
+
+ await createLangflowStreamResponse(request, {
+ apiUrl: "http://localhost:7860",
+ flowId: "flow-1",
+ fetch: fetchMock,
+ prepareSessionId: ({ threadId }) => `tenant-acme:${threadId}`,
+ prepareInput: ({ inputValue, requestBody }) =>
+ `[tenant=${String(requestBody.tenant)}] ${inputValue}`,
+ });
+
+ const payload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
+ expect(payload.input_value).toBe("[tenant=acme] Hello");
+ expect(payload.session_id).toBe("tenant-acme:thread-1");
+ });
+
+ it("returns 400 for malformed chat requests", async () => {
+ const response = await createLangflowStreamResponse(chatRequest({ messages: [] }), {
+ apiUrl: "http://localhost:7860",
+ flowId: "flow-1",
+ });
+
+ expect(response.status).toBe(400);
+ expect(await response.json()).toEqual({
+ error: "Expected a JSON request body containing a non-empty messages array",
+ });
+ });
+
+ it("returns a sanitized 502 when Langflow rejects the run", async () => {
+ const response = await createLangflowStreamResponse(
+ chatRequest({ messages: [{ id: "user-1", role: "user", content: "Hello" }] }),
+ {
+ apiUrl: "http://localhost:7860",
+ flowId: "flow-1",
+ fetch: vi
+ .fn()
+ .mockResolvedValue(
+ Response.json({ detail: "sensitive upstream detail" }, { status: 401 }),
+ ),
+ },
+ );
+
+ expect(response.status).toBe(502);
+ expect(await response.json()).toEqual({
+ error: "Langflow workflow request failed with HTTP 401",
+ });
+ });
+});
+
+function chatRequest(body: Record): Request {
+ return new Request("http://localhost/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
diff --git a/packages/langflow/src/__tests__/workflow.test.ts b/packages/langflow/src/__tests__/workflow.test.ts
new file mode 100644
index 000000000..cbf149528
--- /dev/null
+++ b/packages/langflow/src/__tests__/workflow.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { LangflowRequestError, streamLangflowWorkflow } from "../workflow";
+
+describe("streamLangflowWorkflow", () => {
+ it("calls Workflow API v2 with native AG-UI streaming", async () => {
+ const nativeStream = 'id: 0\ndata: {"type":"RUN_STARTED","threadId":"thread-1"}\n\n';
+ const abortController = new AbortController();
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(nativeStream, { headers: { "Content-Type": "text/event-stream" } }),
+ );
+
+ const response = await streamLangflowWorkflow({
+ apiUrl: "http://localhost:7860/",
+ apiKey: "server-only-key",
+ flowId: "flow-1",
+ inputValue: "Show a chart",
+ sessionId: "thread-1",
+ tweaks: { Agent: { model_name: "gpt-5.5" } },
+ signal: abortController.signal,
+ fetch: fetchMock,
+ });
+
+ expect(await response.text()).toBe(nativeStream);
+ expect(fetchMock).toHaveBeenCalledOnce();
+
+ const [url, init] = fetchMock.mock.calls[0]!;
+ expect(url).toBe("http://localhost:7860/api/v2/workflows");
+ expect(init?.signal).toBe(abortController.signal);
+ expect(new Headers(init?.headers).get("x-api-key")).toBe("server-only-key");
+ expect(JSON.parse(String(init?.body))).toEqual({
+ flow_id: "flow-1",
+ input_value: "Show a chart",
+ session_id: "thread-1",
+ mode: "stream",
+ stream_protocol: "agui",
+ tweaks: { Agent: { model_name: "gpt-5.5" } },
+ });
+ });
+
+ it("redacts upstream error bodies unless debug is enabled", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(Response.json({ detail: "internal deployment detail" }, { status: 422 }));
+
+ const regularError = await captureError(
+ streamLangflowWorkflow({
+ apiUrl: "http://localhost:7860",
+ flowId: "flow-1",
+ inputValue: "Hello",
+ sessionId: "thread-1",
+ fetch: fetchMock,
+ }),
+ );
+ expect(regularError).toBeInstanceOf(LangflowRequestError);
+ expect((regularError as LangflowRequestError).detail).toBeUndefined();
+
+ const debugError = await captureError(
+ streamLangflowWorkflow({
+ apiUrl: "http://localhost:7860",
+ flowId: "flow-1",
+ inputValue: "Hello",
+ sessionId: "thread-1",
+ debug: true,
+ fetch: fetchMock,
+ }),
+ );
+ expect((debugError as LangflowRequestError).detail).toContain("internal deployment detail");
+ });
+});
+
+async function captureError(promise: Promise): Promise {
+ try {
+ await promise;
+ } catch (error) {
+ return error;
+ }
+ throw new Error("Expected promise to reject");
+}
diff --git a/packages/langflow/src/id.ts b/packages/langflow/src/id.ts
new file mode 100644
index 000000000..ab6d7ea79
--- /dev/null
+++ b/packages/langflow/src/id.ts
@@ -0,0 +1,18 @@
+/** Creates an opaque id without requiring a Node-only import. */
+export function createId(): string {
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
+
+ const bytes = new Uint8Array(16);
+ if (globalThis.crypto?.getRandomValues) {
+ globalThis.crypto.getRandomValues(bytes);
+ } else {
+ for (let index = 0; index < bytes.length; index += 1) {
+ bytes[index] = Math.floor(Math.random() * 256);
+ }
+ }
+
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+}
diff --git a/packages/langflow/src/index.ts b/packages/langflow/src/index.ts
new file mode 100644
index 000000000..7992dab5e
--- /dev/null
+++ b/packages/langflow/src/index.ts
@@ -0,0 +1,13 @@
+export { toLangflowInput } from "./input";
+export {
+ createLangflowStreamResponse,
+ type CreateLangflowStreamResponseOptions,
+ type PrepareLangflowInputContext,
+ type PrepareLangflowSessionContext,
+} from "./request-handler";
+export {
+ LangflowRequestError,
+ streamLangflowWorkflow,
+ type LangflowWorkflowOverrides,
+ type StreamLangflowWorkflowOptions,
+} from "./workflow";
diff --git a/packages/langflow/src/input.ts b/packages/langflow/src/input.ts
new file mode 100644
index 000000000..f93bee4f8
--- /dev/null
+++ b/packages/langflow/src/input.ts
@@ -0,0 +1,66 @@
+import type { Message } from "@ag-ui/core";
+
+const CONTENT_MARKER = "]]\u003eopenui:content";
+const CONTEXT_MARKER = "]]\u003eopenui:context";
+
+/**
+ * Returns the newest user turn in a form suitable for Langflow's `input_value`.
+ *
+ * OpenUI action turns include a human-friendly message plus an encoded context
+ * block. This function keeps both, including edited form state, while removing
+ * the transport markers themselves.
+ */
+export function toLangflowInput(messages: Message[]): string {
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const message = messages[index];
+ if (message?.role !== "user") continue;
+
+ const text = actionAwareUserText(message.content);
+ if (text) return text;
+ }
+
+ throw new Error("Expected at least one non-empty user message");
+}
+
+function rawMessageText(content: Extract["content"]): string {
+ if (typeof content === "string") return content;
+ return content
+ .map((part) => (part.type === "text" ? part.text : ""))
+ .filter(Boolean)
+ .join("");
+}
+
+function actionAwareUserText(content: Extract["content"]): string {
+ const raw = rawMessageText(content);
+ const contentIndex = raw.lastIndexOf(CONTENT_MARKER);
+ const contextIndex = raw.lastIndexOf(CONTEXT_MARKER);
+ if (contentIndex === -1 && contextIndex === -1) return raw.trim();
+
+ const contentStart = contentIndex === -1 ? 0 : lineContentStart(raw, contentIndex);
+ const contentEnd = contextIndex === -1 ? raw.length : contextIndex;
+ const visibleContent = raw.slice(contentStart, contentEnd).trim();
+ if (contextIndex === -1) return visibleContent;
+
+ const rawContext = raw.slice(lineContentStart(raw, contextIndex)).trim();
+ return [visibleContent, formatActionContext(rawContext)].filter(Boolean).join("\n");
+}
+
+function lineContentStart(value: string, markerIndex: number): number {
+ const newline = value.indexOf("\n", markerIndex);
+ return newline === -1 ? markerIndex : newline + 1;
+}
+
+function formatActionContext(rawContext: string): string {
+ if (!rawContext) return "";
+
+ try {
+ const parsed = JSON.parse(rawContext) as unknown;
+ const items = Array.isArray(parsed) ? parsed : [parsed];
+ return items
+ .map((item) => (typeof item === "string" ? item : JSON.stringify(item)))
+ .filter((item): item is string => Boolean(item))
+ .join("\n");
+ } catch {
+ return rawContext;
+ }
+}
diff --git a/packages/langflow/src/request-handler.ts b/packages/langflow/src/request-handler.ts
new file mode 100644
index 000000000..f44daa082
--- /dev/null
+++ b/packages/langflow/src/request-handler.ts
@@ -0,0 +1,143 @@
+import { MessageSchema, type Message } from "@ag-ui/core";
+
+import { createId } from "./id";
+import { toLangflowInput } from "./input";
+import {
+ LangflowRequestError,
+ streamLangflowWorkflow,
+ type LangflowWorkflowOverrides,
+} from "./workflow";
+
+export interface PrepareLangflowInputContext {
+ /** Validated OpenUI/AG-UI messages from the incoming request. */
+ messages: Message[];
+ /** The default input derived from the newest user message and action context. */
+ inputValue: string;
+ /** The complete incoming JSON body, including fields such as `threadId`. */
+ requestBody: Record;
+ /** The OpenUI thread id that will be used as Langflow's session id. */
+ sessionId: string;
+}
+
+export interface PrepareLangflowSessionContext {
+ /** The OpenUI thread id from the request, or a generated id when it was omitted. */
+ threadId: string;
+ /** The complete incoming JSON body. Treat its fields as untrusted client input. */
+ requestBody: Record;
+}
+
+export interface CreateLangflowStreamResponseOptions extends LangflowWorkflowOverrides {
+ /** Langflow server root, for example `http://localhost:7860`. */
+ apiUrl: string;
+ /** Flow id sent to `POST /api/v2/workflows`. */
+ flowId: string;
+ /** Optional Langflow API key. Keep this value in the server route. */
+ apiKey?: string;
+ /** Additional upstream headers. */
+ headers?: Record;
+ /** Scope or authorize the Langflow session id before the workflow call. */
+ prepareSessionId?: (context: PrepareLangflowSessionContext) => string | Promise;
+ /** Customize the text sent to Langflow after OpenUI action/form normalization. */
+ prepareInput?: (context: PrepareLangflowInputContext) => string | Promise;
+ /** Include a bounded upstream error excerpt in trusted development responses. */
+ debug?: boolean;
+ /** Override `fetch` for tests or a custom server runtime. */
+ fetch?: typeof fetch;
+}
+
+/**
+ * Converts an OpenUI Agent Interface request into a Langflow Workflow API v2
+ * run and returns Langflow's native AG-UI Server-Sent Event stream.
+ */
+export async function createLangflowStreamResponse(
+ request: Request,
+ options: CreateLangflowStreamResponseOptions,
+): Promise {
+ let body: unknown;
+ try {
+ body = (await request.json()) as unknown;
+ } catch {
+ return badRequest("Expected a JSON request body containing a non-empty messages array");
+ }
+
+ const requestBody = asRecord(body);
+ const messages = parseMessages(requestBody["messages"]);
+ if (!messages) {
+ return badRequest("Expected a JSON request body containing a non-empty messages array");
+ }
+
+ let inputValue: string;
+ try {
+ inputValue = toLangflowInput(messages);
+ } catch (error) {
+ return badRequest(error instanceof Error ? error.message : "Unable to prepare Langflow input");
+ }
+
+ const threadId = optionalNonEmptyString(requestBody["threadId"]) ?? createId();
+ let sessionId = threadId;
+ if (options.prepareSessionId) {
+ try {
+ sessionId = (await options.prepareSessionId({ threadId, requestBody })).trim();
+ if (!sessionId) return badRequest("Prepared Langflow session id must not be empty");
+ } catch (error) {
+ return badRequest(
+ error instanceof Error ? error.message : "Unable to prepare Langflow session id",
+ );
+ }
+ }
+
+ if (options.prepareInput) {
+ try {
+ inputValue = await options.prepareInput({ messages, inputValue, requestBody, sessionId });
+ if (!inputValue.trim()) return badRequest("Prepared Langflow input must not be empty");
+ } catch (error) {
+ return badRequest(
+ error instanceof Error ? error.message : "Unable to prepare Langflow input",
+ );
+ }
+ }
+
+ try {
+ return await streamLangflowWorkflow({
+ apiUrl: options.apiUrl,
+ flowId: options.flowId,
+ inputValue,
+ sessionId,
+ apiKey: options.apiKey,
+ headers: options.headers,
+ signal: request.signal,
+ debug: options.debug,
+ fetch: options.fetch,
+ tweaks: options.tweaks,
+ data: options.data,
+ files: options.files,
+ startComponentId: options.startComponentId,
+ stopComponentId: options.stopComponentId,
+ });
+ } catch (error) {
+ if (request.signal.aborted) {
+ return Response.json({ error: "Langflow workflow request was aborted" }, { status: 499 });
+ }
+
+ const message = error instanceof Error ? error.message : "Langflow workflow request failed";
+ const detail = error instanceof LangflowRequestError ? error.detail : undefined;
+ return Response.json({ error: message, ...(detail ? { detail } : {}) }, { status: 502 });
+ }
+}
+
+function parseMessages(value: unknown): Message[] | undefined {
+ const parsed = MessageSchema.array().safeParse(value);
+ return parsed.success && parsed.data.length > 0 ? parsed.data : undefined;
+}
+
+function optionalNonEmptyString(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+function badRequest(message: string): Response {
+ return Response.json({ error: message }, { status: 400 });
+}
+
+function asRecord(value: unknown): Record {
+ return typeof value === "object" && value !== null ? (value as Record) : {};
+}
diff --git a/packages/langflow/src/workflow.ts b/packages/langflow/src/workflow.ts
new file mode 100644
index 000000000..0a50e893e
--- /dev/null
+++ b/packages/langflow/src/workflow.ts
@@ -0,0 +1,129 @@
+export interface LangflowWorkflowOverrides {
+ /** Per-component parameter overrides keyed by Langflow component id. */
+ tweaks?: Record;
+ /** Optional live-canvas flow definition override. */
+ data?: Record;
+ /** Paths of files already uploaded to Langflow. */
+ files?: string[];
+ /** Start a partial run at this component. */
+ startComponentId?: string;
+ /** Stop a partial run at this component. */
+ stopComponentId?: string;
+}
+
+export interface StreamLangflowWorkflowOptions extends LangflowWorkflowOverrides {
+ /** Langflow server root, for example `http://localhost:7860`. */
+ apiUrl: string;
+ /** Flow id sent to `POST /api/v2/workflows`. */
+ flowId: string;
+ /** Text sent as the workflow's `input_value`. */
+ inputValue: string;
+ /** Langflow session id. Reuse it to preserve conversation memory. */
+ sessionId: string;
+ /** Optional Langflow API key, sent only as the server-side `x-api-key` header. */
+ apiKey?: string;
+ /** Additional upstream headers. Core content and authentication headers take precedence. */
+ headers?: Record;
+ /** Aborts the upstream workflow request. */
+ signal?: AbortSignal;
+ /** Include a bounded upstream response excerpt in thrown errors. Defaults to `false`. */
+ debug?: boolean;
+ /** Override `fetch` for tests or a custom server runtime. */
+ fetch?: typeof fetch;
+}
+
+export class LangflowRequestError extends Error {
+ readonly status: number;
+ readonly detail?: string;
+
+ constructor(status: number, detail?: string) {
+ super(`Langflow workflow request failed with HTTP ${status}`);
+ this.name = "LangflowRequestError";
+ this.status = status;
+ this.detail = detail;
+ }
+}
+
+/**
+ * Starts a streaming Workflow API v2 run using Langflow's native AG-UI protocol.
+ * The returned response can be passed directly to OpenUI's `agUIAdapter()`.
+ */
+export async function streamLangflowWorkflow({
+ apiUrl,
+ flowId,
+ inputValue,
+ sessionId,
+ apiKey,
+ headers: extraHeaders,
+ signal,
+ debug = false,
+ fetch: customFetch,
+ tweaks,
+ data,
+ files,
+ startComponentId,
+ stopComponentId,
+}: StreamLangflowWorkflowOptions): Promise {
+ const baseUrl = requiredValue(apiUrl, "apiUrl").replace(/\/+$/, "");
+ const normalizedFlowId = requiredValue(flowId, "flowId");
+ const normalizedInput = requiredValue(inputValue, "inputValue");
+ const normalizedSessionId = requiredValue(sessionId, "sessionId");
+ const fetchImpl = customFetch ?? globalThis.fetch.bind(globalThis);
+
+ const headers = new Headers(extraHeaders);
+ headers.set("Accept", "text/event-stream");
+ headers.set("Content-Type", "application/json");
+ if (apiKey?.trim()) headers.set("x-api-key", apiKey.trim());
+
+ const body = {
+ flow_id: normalizedFlowId,
+ input_value: normalizedInput,
+ session_id: normalizedSessionId,
+ mode: "stream",
+ stream_protocol: "agui",
+ ...(tweaks ? { tweaks } : {}),
+ ...(data ? { data } : {}),
+ ...(files ? { files } : {}),
+ ...(startComponentId ? { start_component_id: startComponentId } : {}),
+ ...(stopComponentId ? { stop_component_id: stopComponentId } : {}),
+ };
+
+ const upstream = await fetchImpl(`${baseUrl}/api/v2/workflows`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ cache: "no-store",
+ signal,
+ });
+
+ if (!upstream.ok) {
+ const detail = debug ? await responseExcerpt(upstream) : undefined;
+ throw new LangflowRequestError(upstream.status, detail);
+ }
+ if (!upstream.body) {
+ throw new Error("Langflow returned an empty workflow stream");
+ }
+
+ return new Response(upstream.body, {
+ status: upstream.status,
+ headers: {
+ "Content-Type": upstream.headers.get("Content-Type") ?? "text/event-stream",
+ "Cache-Control": "no-cache, no-transform",
+ },
+ });
+}
+
+function requiredValue(value: string, name: string): string {
+ const normalized = value.trim();
+ if (!normalized) throw new Error(`Langflow ${name} must not be empty`);
+ return normalized;
+}
+
+async function responseExcerpt(response: Response): Promise {
+ try {
+ const text = (await response.text()).trim();
+ return text ? text.slice(0, 2_000) : undefined;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/packages/langflow/tsconfig.json b/packages/langflow/tsconfig.json
new file mode 100644
index 000000000..050ddeb9a
--- /dev/null
+++ b/packages/langflow/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "../../tsconfig.json",
+ "include": ["src/**/*"],
+ "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"],
+ "compilerOptions": {
+ "moduleResolution": "bundler",
+ "module": "ESNext",
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "noEmit": true
+ }
+}
diff --git a/packages/langflow/tsconfig.test.json b/packages/langflow/tsconfig.test.json
new file mode 100644
index 000000000..60b3001d5
--- /dev/null
+++ b/packages/langflow/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/langflow/tsdown.config.ts b/packages/langflow/tsdown.config.ts
new file mode 100644
index 000000000..bd5f1a3e1
--- /dev/null
+++ b/packages/langflow/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/pnpm-lock.yaml b/pnpm-lock.yaml
index 0c7eb2385..648129511 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -340,6 +340,16 @@ importers:
specifier: ^4.2.0
version: 4.4.3
+ packages/langflow:
+ dependencies:
+ '@ag-ui/core':
+ specifier: ^0.0.57
+ version: 0.0.57
+ devDependencies:
+ vitest:
+ specifier: ^4.1.0
+ version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(jsdom@26.1.0)(vite@7.3.6(@types/node@20.19.43)(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/observability:
devDependencies:
vitest: