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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ pnpm --filter @openuidev/react-lang build

Use the package name that matches the area you are changing.

If you are a coding agent (or working with one), read the `AGENTS.md` in the package you are changing before editing, for example `packages/react-headless/AGENTS.md`. For building apps on the Agent Interface SDK, the self-contained consumer guide is `docs/public/AGENTS.md`, served at `/AGENTS.md` on the docs site.

## Before Opening a Pull Request

Before opening a pull request, please make sure that:
Expand Down
3 changes: 3 additions & 0 deletions docs/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export async function GET() {
const lines: string[] = [];
lines.push("# Documentation");
lines.push("");
lines.push(
"- [AGENTS.md — complete agent guide for the Agent Interface SDK](/AGENTS.md): self-contained instructions for coding agents",
);
for (const page of source.getPages()) {
lines.push(`- [${page.data.title}](${page.url}): ${page.data.description}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,39 +26,32 @@ Its mid-turn activity (reasoning and tool runs) surfaces as cards too.

| Piece | File | Role |
| --- | --- | --- |
| Frontend | `src/app/page.tsx` | A single `<AgentInterface>` whose `llm.send` posts to the bridge with `streamProtocol: openAIReadableStreamAdapter()`. Generates the OpenUI Lang system prompt and sends it with each turn. |
| Bridge route | `src/app/api/chat/route.ts` | Drives a pi `AgentSession` and re-emits its events as NDJSON OpenAI chunks (`delta.content` is OpenUI Lang). |
| Session registry | `src/lib/pi-session.ts` | One persistent `AgentSession` per chat thread, keyed by the `x-conversation-id` header. |
| Frontend | `src/app/page.tsx` | A single `<AgentInterface>` wired to `fetchLLM`, which posts each turn to the bridge and parses the reply with `openAIReadableStreamAdapter()`. |
| Bridge route | `src/app/api/chat/route.ts` | Computes the OpenUI Lang system prompt server-side, drives a pi `AgentSession`, and re-emits its events as NDJSON OpenAI chunks (`delta.content` is OpenUI Lang). |
| Session registry | `src/lib/pi-session.ts` | One persistent `AgentSession` per chat thread, keyed by the `threadId` that `fetchLLM` sends in the request body. |
| Agent | `@earendil-works/pi-coding-agent` | The pi coding agent: `read` / `bash` / `edit` / `write` on the workspace you choose at launch. |

Everything runs in **one Next.js process**: the App-Router route _is_ the backend. The pi SDK is embedded directly (no separate server), so there is no second service and no CORS. Each chat thread maps to one persistent pi `AgentSession`, so multi-turn context is preserved.

## Connecting the frontend

The client is a single `<AgentInterface>` (the artifact chat surface with sidebar thread history). It generates the OpenUI Lang system prompt from the component library, sends it with each turn via its `llm.send`, and parses the response with `openAIReadableStreamAdapter()` (NDJSON OpenAI chunks):
The client is a single `<AgentInterface>` (the artifact chat surface with sidebar thread history) wired to `fetchLLM`. It posts each turn to the bridge — including the thread's stable client-generated id as `threadId` in the body — and parses the response with `openAIReadableStreamAdapter()` (NDJSON OpenAI chunks):

```tsx
import {
AgentInterface,
fetchLLM,
openAIMessageFormat,
openAIReadableStreamAdapter,
type ChatLLM,
} from "@openuidev/react-ui";
import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-lib";

const systemPrompt = openuiLibrary.prompt(openuiPromptOptions);

const llm: ChatLLM = {
// threadId is stable per thread, so each thread maps to its own persistent pi AgentSession.
send: ({ threadId, messages, signal }) =>
fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json", "x-conversation-id": threadId },
body: JSON.stringify({ systemPrompt, messages: openAIMessageFormat.toApi(messages) }),
signal,
}),
streamProtocol: openAIReadableStreamAdapter(),
};
import { openuiLibrary } from "@openuidev/react-ui/genui-lib";

// threadId is stable per thread, so each thread maps to its own persistent pi AgentSession.
const llm = fetchLLM({
url: "/api/chat",
streamAdapter: openAIReadableStreamAdapter(),
messageFormat: openAIMessageFormat,
});

<AgentInterface
llm={llm}
Expand All @@ -67,11 +60,11 @@ const llm: ChatLLM = {
/>;
```

The `systemPrompt` generated here is the **same** string the backend injects into pi, so the model's markup always matches the component set the renderer knows.
The client does **not** generate the OpenUI Lang system prompt. The route computes it server-side from the **same** `openuiLibrary` this page renders with, so the model's markup always matches the component set the renderer knows — and the client doesn't have to ship the (large) prompt. A request can still pass `systemPrompt` in the body as an optional override.

## The bridge route

The route keys a persistent `AgentSession` by the `x-conversation-id` header, injects the OpenUI Lang prompt via `appendSystemPrompt`, subscribes to the session's events, and re-emits them as NDJSON OpenAI chunks. Because pi keeps its own transcript, only the newest user turn is sent to `session.prompt()`:
The route keys a persistent `AgentSession` by the `threadId` that `fetchLLM` sends in the request body (an `x-conversation-id` header is kept only as a fallback for older/custom clients), computes the OpenUI Lang prompt server-side (`body.systemPrompt` can override it), injects it via `appendSystemPrompt`, subscribes to the session's events, and re-emits them as NDJSON OpenAI chunks. Because pi keeps its own transcript, only the newest user turn is sent to `session.prompt()`:

```ts
// lib/pi-session.ts: one AgentSession per conversation
Expand All @@ -89,7 +82,12 @@ const { session } = await createAgentSession({ cwd, agentDir, settingsManager, r
```

```ts
// app/api/chat/route.ts: translate pi events into OpenAI NDJSON
// app/api/chat/route.ts: server-side OpenUI prompt, then pi events into OpenAI NDJSON
const defaultSystemPrompt = openuiLibrary.prompt(openuiPromptOptions);

const conversationId =
body.threadId || req.headers.get("x-conversation-id") || crypto.randomUUID();

const unsubscribe = session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
enqueue(ndjsonChunk({ content: event.assistantMessageEvent.delta }));
Expand All @@ -99,7 +97,7 @@ await session.prompt(lastUserText);
enqueue(ndjsonChunk({}, "stop"));
```

The pi SDK is ESM-only, so it is loaded with a native dynamic `import()` and marked as a webpack external in `next.config.ts` (the example runs with `--webpack`).
The pi SDK is ESM-only, so it is loaded with a native dynamic `import()` and marked as a webpack external in `next.config.ts` (the example runs with `--webpack`). The same config also externalizes `@openuidev/react-ui/genui-lib` — scoped to imports issued from the API routes — because the route computes the OpenUI prompt from it server-side and Next's route-handler graph would otherwise reject its client-oriented React imports at build time.

## Thinking states

Expand Down Expand Up @@ -143,12 +141,12 @@ The pi SDK resolves a model from either an environment API key (`ANTHROPIC_API_K

```
examples/harnesses/pi-agent-harness/
|- src/app/page.tsx # <AgentInterface> wired to openAIReadableStreamAdapter()
|- src/app/api/chat/route.ts # pi event stream into NDJSON OpenAI chunks
|- src/app/page.tsx # <AgentInterface> wired to fetchLLM + openAIReadableStreamAdapter()
|- src/app/api/chat/route.ts # server-side OpenUI prompt; pi event stream into NDJSON OpenAI chunks
|- src/lib/pi-session.ts # one persistent pi AgentSession per conversation
|- src/library.ts # the OpenUI component library (re-exported)
|- scripts/launch.mjs # picks the agent workspace, then starts Next
|- next.config.ts # keeps the ESM-only pi SDK external
|- next.config.ts # keeps the ESM-only pi SDK (and, for API routes, genui-lib) external
```

## Run the example
Expand Down
58 changes: 30 additions & 28 deletions docs/public/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ Everything imports from `@openuidev/react-ui`. Read the Rules first.
`openAIReadableStreamAdapter()` with `openAIMessageFormat`; `agUIAdapter()` with
`identityMessageFormat` (the default); `langGraphAdapter()` with
`langGraphMessageFormat`.
- **OpenUI Cloud is two planes.** `llm` is a `ChatLLM` whose `send` posts to your
own `/api/chat` route, which proxies to Cloud with `THESYS_API_KEY`. `storage`
- **OpenUI Cloud is two planes.** `llm` is `fetchLLM({ url: "/api/chat", ... })`,
and your own `/api/chat` route proxies to Cloud with `THESYS_API_KEY`. `storage`
is `useOpenuiCloudStorage({ token: "/api/frontend-token" })`. `THESYS_API_KEY`
is server-side only, never in the browser.
- **On Cloud, send only the latest message.** The Responses API replays history
from the conversation: `input: openAIConversationMessageFormat.toApi(messages.slice(-1))`.
from the conversation, so wrap the message format:
`toApi: (msgs) => openAIConversationMessageFormat.toApi(msgs.slice(-1))`.
- **For Cloud, the component set, storage hook, and artifacts come from
`@openuidev/thesys`** (`chatLibrary`, `useOpenuiCloudStorage`,
`artifactRenderers`, `artifactCategories`); the server route uses
Expand Down Expand Up @@ -66,9 +67,9 @@ import "@openuidev/thesys/styles.css";

import {
AgentInterface,
fetchLLM,
openAIConversationMessageFormat,
openAIResponsesAdapter,
type ChatLLM,
} from "@openuidev/react-ui";
import {
chatLibrary,
Expand All @@ -77,20 +78,15 @@ import {
artifactCategories,
} from "@openuidev/thesys";

const llm: ChatLLM = {
// Cloud replays history from the conversation, so send only the latest message.
send: ({ threadId, messages, signal }) =>
fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
threadId,
input: openAIConversationMessageFormat.toApi(messages.slice(-1)),
}),
signal,
}),
streamProtocol: openAIResponsesAdapter(),
};
const llm = fetchLLM({
url: "/api/chat",
streamAdapter: openAIResponsesAdapter(),
// Cloud replays history from the conversation, so this wrapper sends only the latest message.
messageFormat: {
toApi: (msgs) => openAIConversationMessageFormat.toApi(msgs.slice(-1)),
fromApi: openAIConversationMessageFormat.fromApi,
},
});

export default function App() {
const storage = useOpenuiCloudStorage({
Expand All @@ -116,7 +112,9 @@ export default function App() {
import { artifactTool, createResponsesInstructions } from "@openuidev/thesys-server";

export async function POST(req: Request) {
const { threadId, input } = await req.json();
// fetchLLM POSTs { threadId, runId, messages, tools, context }; here messages
// is the latest message only (the client's messageFormat slices).
const { threadId, messages } = await req.json();
const upstream = await fetch("https://api.thesys.dev/v1/embed/responses", {
method: "POST",
headers: {
Expand All @@ -126,7 +124,7 @@ export async function POST(req: Request) {
body: JSON.stringify({
model: "openai/gpt-5",
conversation: threadId, // Cloud stores and replays the conversation
input,
input: messages,
stream: true,
store: true,
tools: [artifactTool()], // managed slides/report tool
Expand Down Expand Up @@ -211,12 +209,14 @@ export async function POST(req: Request) {
}
```

`fetchLLM` POSTs `{ threadId, messages: messageFormat.toApi(messages) }` to `url`
and forwards the abort signal. Here `messages` is the **full thread history** (the
SDK loads it from `storage` and holds it client-side), so forward all of it to your
provider. (Contrast Cloud, where you send only the latest because Cloud replays the
conversation.) Your route must **stream** and **close the stream when done** (the
client's `isRunning` flips back to `false` only on close).
`fetchLLM` POSTs an AG-UI `RunAgentInput`-shaped body to `url` — `{ threadId,
runId, messages: messageFormat.toApi(messages), tools: [], context: [] }`, with a
fresh `runId` per send — and forwards the abort signal. Routes may destructure
just `{ messages }`. Here `messages` is the **full thread history** (the SDK loads
it from `storage` and holds it client-side), so forward all of it to your
provider. (Contrast Cloud, where the wrapped message format sends only the latest
because Cloud replays the conversation.) Your route must **stream** and **close
the stream when done** (the client's `isRunning` flips back to `false` only on close).

## `<AgentInterface>` props

Expand All @@ -241,8 +241,10 @@ Children are slot overrides (see Customization).

## Backends: `ChatLLM` and adapters

The `llm` is a `ChatLLM`. `fetchLLM` builds one for the common case; for full
control, write the object directly (this is what the Cloud quickstart does).
The `llm` is a `ChatLLM`. `fetchLLM` builds one for anything that POSTs to an
HTTP endpoint and streams the response back (both quickstarts use it). Write the
object directly only for genuinely custom cases — e.g. synthesizing the stream
client-side when there is no HTTP endpoint at all.

```ts
interface ChatLLM {
Expand Down
34 changes: 26 additions & 8 deletions examples/fastapi-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,17 @@ fastapi-backend/
│ ├── .env.example # Environment template
│ ├── pyproject.toml # Python dependencies
│ └── app/
│ └── main.py # Streaming chat endpoint
│ ├── main.py # Streaming chat endpoint
│ └── system_prompt.txt # Generated OpenUI system prompt (see below)
└── frontend/
├── package.json
├── vite.config.js # Vite + proxy to localhost:8000
├── index.html
├── scripts/
│ └── generate-prompt.mjs # Writes backend/app/system_prompt.txt
└── src/
├── main.jsx
├── App.jsx # Identical to genui-chat-app
├── App.jsx # fetchLLM + AgentInterface
└── index.css
```

Expand All @@ -60,7 +63,17 @@ Add your key to `backend/.env`:
OPENAI_API_KEY=sk-...
```

### 2. Start the backend
### 2. Generate the system prompt (first run only)

The backend reads the OpenUI system prompt from `backend/app/system_prompt.txt` at startup. A generated copy is checked in, so this step is only needed if the file is missing or after upgrading `@openuidev/react-ui`:

```bash
cd frontend
npm install
npm run generate:prompt
```

### 3. Start the backend

```bash
cd backend
Expand All @@ -76,7 +89,7 @@ uvicorn app.main:app --reload

The API will be available at `http://localhost:8000`.

### 3. Start the frontend
### 4. Start the frontend

```bash
cd frontend
Expand All @@ -92,13 +105,18 @@ Open [http://localhost:5173](http://localhost:5173).

A FastAPI endpoint that:

1. Receives `{ systemPrompt, messages }` as JSON
2. Forwards the conversation to the OpenAI streaming API
3. Yields each chunk as a NDJSON line — the same format the JavaScript SDK's `toReadableStream()` produces
1. Loads the OpenUI system prompt from `app/system_prompt.txt` at startup (fails fast with a pointer to `npm run generate:prompt` if the file is missing)
2. Receives `{ threadId, runId, messages, tools, context }` as JSON — the body `fetchLLM` sends; `systemPrompt` may optionally be included to override the server-side default
3. Forwards the conversation to the OpenAI streaming API
4. Yields each chunk as a NDJSON line — the same format the JavaScript SDK's `toReadableStream()` produces

### `frontend/src/App.jsx`

Identical to the one scaffolded by `npx @openuidev/cli create`. Uses `openAIReadableStreamAdapter()` to parse the NDJSON stream from the backend — no frontend changes were needed to switch from Next.js to FastAPI.
Wires `AgentInterface` to the backend with `fetchLLM({ url: "/api/chat", streamAdapter: openAIReadableStreamAdapter(), messageFormat: openAIMessageFormat })`. The system prompt is owned by the backend, so the client sends only the conversation — no frontend changes were needed to switch from Next.js to FastAPI.

### `frontend/scripts/generate-prompt.mjs`

A small Node script that imports `openuiLibrary` and `openuiPromptOptions` from `@openuidev/react-ui/genui-lib` and writes the generated prompt to `backend/app/system_prompt.txt`. Run it via `npm run generate:prompt` after upgrading `@openuidev/react-ui`.

## Learn More

Expand Down
12 changes: 11 additions & 1 deletion examples/fastapi-backend/backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Minimal FastAPI backend — streams OpenAI completions as NDJSON."""
import os
from pathlib import Path

from dotenv import load_dotenv
from fastapi import FastAPI
Expand All @@ -12,15 +13,24 @@
client = AsyncOpenAI()
MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.5")

PROMPT_FILE = Path(__file__).parent / "system_prompt.txt"
if not PROMPT_FILE.is_file():
raise RuntimeError(
f"Missing {PROMPT_FILE} — generate it by running `npm run generate:prompt` "
"in the frontend/ directory."
)
DEFAULT_SYSTEM_PROMPT = PROMPT_FILE.read_text(encoding="utf-8")

app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])


@app.post("/api/chat")
async def chat(body: dict):
system_prompt = body.get("systemPrompt") or DEFAULT_SYSTEM_PROMPT
response = await client.chat.completions.create(
model=MODEL,
messages=[{"role": "system", "content": body["systemPrompt"]}, *body["messages"]],
messages=[{"role": "system", "content": system_prompt}, *body["messages"]],
stream=True,
)

Expand Down
Loading
Loading