Skip to content
Merged
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
160 changes: 160 additions & 0 deletions docs/content/docs/openui-lang/examples/harnesses/pi-agent-harness.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
---
title: pi Agent Harness
description: Chat with the pi coding agent (a real read/bash/edit/write agent) and get its answers as live generative UI, bridged through the pi SDK over an OpenAI-compatible stream.
---

Anything that can stream text can drive OpenUI's renderer, including a full **coding agent**. This example connects [pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (`@earendil-works/pi-coding-agent`), running its default `read` / `bash` / `edit` / `write` tools, to `<FullScreen>`. pi's [OpenUI Lang](/docs/openui-lang/overview) instructions are appended to its system prompt, so it emits component markup instead of markdown and its streamed answers render live as generative UI.

Its mid-turn activity (reasoning and tool runs) surfaces as cards too.

[View source on GitHub →](https://github.com/thesysdev/openui/tree/main/examples/harnesses/pi-agent-harness)

<div className="bg-[rgba(0,0,0,0.05)] dark:bg-gray-900 rounded-2xl h-[500px] flex p-2">
<video
src="/videos/pi-agent-harness.mp4"
noControls
playsInline
muted
preload="metadata"
className="w-full rounded-lg m-auto"
autoPlay
loop
/>
</div>

## How it connects

| Piece | File | Role |
| --- | --- | --- |
| Frontend | `src/app/page.tsx` | A single `<FullScreen>` 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. |
| 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 `<FullScreen>`. It generates the OpenUI Lang system prompt from the component library, sends it with each turn, and parses the response with `openAIReadableStreamAdapter()` (NDJSON OpenAI chunks):

```tsx
import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless";
import { FullScreen } from "@openuidev/react-ui";
import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-lib";

const systemPrompt = openuiLibrary.prompt(openuiPromptOptions);

<FullScreen
// Each thread gets a stable id, so it maps to its own persistent pi AgentSession.
createThread={async () => ({ id: crypto.randomUUID(), title: "New chat", createdAt: Date.now() })}
processMessage={async ({ threadId, messages, abortController }) =>
fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json", "x-conversation-id": threadId },
body: JSON.stringify({ systemPrompt, messages: openAIMessageFormat.toApi(messages) }),
signal: abortController.signal,
})
}
streamProtocol={openAIReadableStreamAdapter()}
componentLibrary={openuiLibrary}
agentName="OpenUI Agent Harness"
/>;
```

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 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()`:

```ts
// lib/pi-session.ts: one AgentSession per conversation
const { createAgentSession, DefaultResourceLoader, getAgentDir, SettingsManager } =
await import("@earendil-works/pi-coding-agent");

const loader = new DefaultResourceLoader({
cwd,
agentDir,
settingsManager,
appendSystemPrompt: [systemPrompt], // makes pi speak OpenUI Lang
});
await loader.reload();
const { session } = await createAgentSession({ cwd, agentDir, settingsManager, resourceLoader: loader });
```

```ts
// app/api/chat/route.ts: translate pi events into OpenAI NDJSON
const unsubscribe = session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
enqueue(ndjsonChunk({ content: event.assistantMessageEvent.delta }));
}
});
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`).

## Thinking states

The route also forwards pi's **reasoning** and **tool executions**, mapped onto OpenAI `tool_calls`, which OpenUI renders as cards in a collapsible "behind the scenes" section:

```ts
} else if (event.type === "tool_execution_start") {
// e.g. read {"path":"package.json"}, bash {"command":"ls -la"}
enqueue(
toolStartChunk(indexFor(event.toolCallId), event.toolCallId, event.toolName, JSON.stringify(event.args)),
);
}
// thinking_delta events stream into a single "Thinking" card the same way.
```

Tool _results_ (command output) are not rendered yet: OpenUI's streaming path renders tool calls but not inline results, so surfacing those would take a custom adapter/renderer.

## Choosing the workspace

Because this is a _coding_ agent, you pick the directory it operates on at launch. `pnpm dev` runs a small wrapper that takes the path (or prompts for it) and starts Next with `PI_AGENT_CWD` set:

```bash
pnpm dev -- /path/to/your/project # explicit
pnpm dev # prompts for the workspace
```

The agent's `read` / `bash` / `edit` / `write` tools act on that directory.

## Security

This example executes real code on your machine. The agent has the full `read` / `bash` / `edit` / `write` toolset, tools execute **without an approval prompt**, and the route is **unauthenticated**, so treat reaching the port as remote code execution.

- Local, single-user use is equivalent to running the pi CLI yourself.
- For anything networked: set `PI_WEB_TOOLS=read-only`, put it behind auth, bind to loopback (`next start -H 127.0.0.1`), and sandbox the agent. `PI_AGENT_CWD` is a discovery root, **not** a sandbox: `bash` can escape it.

## Authentication

The pi SDK resolves a model from either an environment API key (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, ...) **or** an existing `~/.pi/agent` login from the pi CLI. The pi CLI is **not** required; an API key alone works.

## Project layout

```
examples/harnesses/pi-agent-harness/
|- src/app/page.tsx # <FullScreen> wired to openAIReadableStreamAdapter()
|- src/app/api/chat/route.ts # 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
```

## Run the example

From the repo root, install workspace deps once, then run the example pointed at a project:

```bash
pnpm install

cd examples/harnesses/pi-agent-harness
cp .env.example .env # set a provider API key (skip if you have a pi login)
pnpm dev -- /path/to/your/project
```

Open [http://localhost:3000](http://localhost:3000) and try "Summarize the files in this project as a card" or "Read package.json and list its scripts". pi's tools run and the result renders as generative UI.
4 changes: 3 additions & 1 deletion docs/content/docs/openui-lang/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"examples/react-native",
"examples/vercel-ai-chat",
"examples/langgraph-chat",
"examples/react-email"
"examples/react-email",
"---Harnesses---",
"examples/harnesses/pi-agent-harness"
]
}
Binary file added docs/public/videos/pi-agent-harness.mp4
Binary file not shown.
25 changes: 25 additions & 0 deletions examples/harnesses/pi-agent-harness/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# A model provider API key is all this app needs — the pi CLI is NOT required.
# Set ONE of the following, or leave them all blank to use an existing ~/.pi/agent login.
# (Next.js loads this file automatically; copy it to `.env` and fill in a key.)

# Anthropic (Claude)
ANTHROPIC_API_KEY=

# OpenAI
# OPENAI_API_KEY=

# Google Gemini
# GEMINI_API_KEY=

# ----------------------------------------------------------------------------
# Optional app settings (all have sensible defaults)
# ----------------------------------------------------------------------------

# Workspace directory the coding agent reads/writes in (default: the server's cwd)
# PI_AGENT_CWD=

# Set to "read-only" to disable the bash/edit/write tools (recommended if exposed)
# PI_WEB_TOOLS=full

# Dev/prod server port
# PORT=3000
19 changes: 19 additions & 0 deletions examples/harnesses/pi-agent-harness/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# dependencies
/node_modules

# next.js build output
/.next/
/out/
/build

# typescript
next-env.d.ts
*.tsbuildinfo

# env files
.env*
!.env.example

# misc
.DS_Store
*.log
139 changes: 139 additions & 0 deletions examples/harnesses/pi-agent-harness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# OpenUI + pi Agent Harness

A generative-UI frontend where you chat with the **pi coding agent** and get **generative UI**
answers — live React components instead of plain markdown — rendered with
[OpenUI](https://openui.com).

The App-Router route `src/app/api/chat/route.ts` _is_ the backend bridge to the pi SDK
([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)),
so there's no second server and no CORS. Unlike the other examples, the "agent" here is a real
coding agent with `read` / `bash` / `edit` / `write` tools that act on a workspace you choose at
launch — see **Security** below.

## How it works

```
Browser (src/app/page.tsx)
FullScreen chat ──POST /api/chat ({ systemPrompt, messages })──► route.ts (runtime=nodejs)
+ openuiLibrary x-conversation-id: <threadId> │
renderer ◄──NDJSON OpenAI chunks (delta.content = OpenUI Lang)─────────┤
src/lib/pi-session.ts
Map<threadId, AgentSession>
createAgentSession({ resourceLoader with
appendSystemPrompt: [openui prompt] })
session.subscribe() → text/thinking/tool events
session.prompt(lastUserText) ▼
pi SDK (read/bash/edit/write)
operating on the server cwd
```

- **Transport:** the frontend's `openAIReadableStreamAdapter()` parses **NDJSON** OpenAI
`chat.completion.chunk`s (one JSON object per line). The route translates pi's `text_delta`
events into `delta.content`, and pi's reasoning + tool executions into `delta.tool_calls`.
- **System prompt:** `page.tsx` generates the OpenUI Lang prompt client-side
(`openuiLibrary.prompt(openuiPromptOptions)`) and sends it in the request body; the route
injects it into pi via `DefaultResourceLoader({ appendSystemPrompt: [...] })`, so the backend
prompt and the frontend renderer always reference the same component library.
- **Sessions:** each chat thread (a stable id sent as the `x-conversation-id` header) maps to
one persistent pi `AgentSession`, so multi-turn context is preserved.

## Prerequisites

All you need is a **model provider API key**. You do **not** need the pi CLI installed — this app
embeds the pi SDK and reads credentials directly. Pick one of:

1. **An API key (recommended — no pi required).** Copy `.env.example` to `.env` and set a provider
key, e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY`. With just a key and no
other config, the SDK resolves that provider's default model.
2. **An existing pi login.** If you already use the pi CLI, the app automatically picks up your
`~/.pi/agent` auth and settings (model, provider, thinking level) — no `.env` needed.

If neither resolves, the chat still streams but opens with the SDK's "no models available" notice.

> **Note:** a Claude _subscription_ OAuth token (from `pi` login) lives in `~/.pi/agent` and relies
> on pi's refresh flow. For a self-contained deployment, prefer a plain **API key**.

## Run

From the repo root, install workspace deps once:

```bash
pnpm install
```

Then, from this example, set a provider key and point the agent at a project to work on:

```bash
cd examples/pi-agent-harness
cp .env.example .env # set a provider API key (skip if using an existing pi login)

# Point the agent at the project you want it to work on:
pnpm dev -- /path/to/your/project
```

`pnpm dev` (no path) prompts you for the workspace; `PI_AGENT_CWD=/path pnpm dev` sets it without a
prompt. The launcher prints the resolved workspace before the server starts. (`build` doesn't need
a workspace — the agent only runs at request time, i.e. under `dev`/`start`.)

Then open the printed URL (default http://localhost:3000). Try:

- "Show me a card summarizing the files in this directory" → renders live OpenUI components.
- "Read package.json and list its scripts" → pi's `read` tool runs (you'll see a tool card).

Production:

```bash
pnpm build && pnpm start
```

## Configuration

| Env var | Default | Purpose |
| -------------- | --------------- | ---------------------------------------------------- |
| `PI_AGENT_CWD` | `process.cwd()` | Workspace directory the coding agent reads/writes in |
| `PI_WEB_TOOLS` | `full` | Set to `read-only` to disable `bash`/`edit`/`write` |
| `PORT` | `3000` | Dev/prod server port |

## Thinking states

The model's reasoning (a streaming "Thinking" card) and each tool run (`read`/`bash`/`edit`/`write`
with its input) are forwarded as `tool_calls` and render in OpenUI's collapsible "behind the
scenes" section, like the pi CLI. The "Thinking" card only appears when your model emits
reasoning. Tool _results_ (command output) aren't shown yet — OpenUI's streaming path renders
tool calls but not inline results; surfacing those needs a custom adapter/renderer.

## Why `--webpack`

The pi SDK is an **ESM-only** package (its `exports` map has no `require` entry) and a Node-only
chain that spawns bash, uses `import.meta`, and reads its own prompt/skill/theme files from disk —
it must run as a real Node module at runtime, never bundled. `src/lib/pi-session.ts` loads it via
a native dynamic `import()`, and `next.config.ts` marks it as an external so the bundler keeps it
that way. The dev/build scripts use `--webpack` because this external setup is the most reliable;
you can experiment with the default Turbopack + `serverExternalPackages` if you prefer.

## Notes & limitations

- **One turn at a time per conversation.** A second request on a conversation whose turn is still
streaming gets a "please wait" notice rather than interrupting the in-flight turn.
- **In-memory, single-instance sessions.** They're pinned to `globalThis` (so they survive dev
hot-reload) but reset on a full restart and aren't shared across server processes.

## Security

**This endpoint runs a real coding agent and is unauthenticated.** By default the agent has the
full toolset (`read`, `bash`, `edit`, `write`) and tools execute with **no human approval** (the
interactive approval prompt only exists in the pi TUI). It runs with the launching user's
permissions on `PI_AGENT_CWD`, and `bash` is **not** confined to that directory. Treat the
ability to reach this port as remote code execution.

- **Local, single-user use** (the default) is equivalent to running the pi CLI yourself — fine.
- **Any networked / shared / multi-user exposure requires protection.** At minimum:
- set `PI_WEB_TOOLS=read-only` to disable `bash`/`edit`/`write`;
- put it behind authentication / a reverse proxy and bind to loopback
(`next start -H 127.0.0.1`) instead of the default `0.0.0.0`;
- run the agent in an OS-level sandbox/container with dropped privileges and no network.

`PI_AGENT_CWD` is a discovery root, **not** a security boundary — `bash` can escape it.
18 changes: 18 additions & 0 deletions examples/harnesses/pi-agent-harness/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";

const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);

export default eslintConfig;
Loading
Loading