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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
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.
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.
Expand All @@ -27,11 +27,11 @@ Its mid-turn activity (reasoning and tool runs) surfaces as cards too.
| 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). |
| 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. |
| 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.
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

Expand All @@ -45,7 +45,7 @@ import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-li
const systemPrompt = openuiLibrary.prompt(openuiPromptOptions);

<FullScreen
// Each thread gets a stable id, so it maps to its own persistent pi AgentSession.
// 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", {
Expand All @@ -65,7 +65,7 @@ The `systemPrompt` generated here is the **same** string the backend injects int

## 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 `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
Expand All @@ -76,14 +76,14 @@ const loader = new DefaultResourceLoader({
cwd,
agentDir,
settingsManager,
appendSystemPrompt: [systemPrompt], // makes pi speak OpenUI Lang
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
// 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 }));
Expand All @@ -93,7 +93,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`).

## Thinking states

Expand Down Expand Up @@ -126,23 +126,23 @@ The agent's `read` / `bash` / `edit` / `write` tools act on that directory.

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.
- 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.
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/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
|- next.config.ts # keeps the ESM-only Pi SDK external
```

## Run the example
Expand All @@ -153,7 +153,7 @@ From the repo root, install workspace deps once, then run the example pointed at
pnpm install

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

Expand Down
30 changes: 15 additions & 15 deletions examples/harnesses/pi-agent-harness/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# OpenUI + pi Agent Harness
# OpenUI + Pi Agent Harness

A generative-UI frontend where you chat with the **pi coding agent** and get **generative UI**
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
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
Expand All @@ -26,7 +26,7 @@ launch — see **Security** below.
session.subscribe() → text/thinking/tool events
session.prompt(lastUserText) ▼
pi SDK (read/bash/edit/write)
Pi SDK (read/bash/edit/write)
operating on the server cwd
```

Expand All @@ -35,20 +35,20 @@ launch — see **Security** below.
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
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.
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:
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
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
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.
Expand All @@ -68,7 +68,7 @@ Then, from this example, set a provider key and point the agent at a project to

```bash
cd examples/pi-agent-harness
cp .env.example .env # set a provider API key (skip if using an existing pi login)
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
Expand Down Expand Up @@ -101,13 +101,13 @@ pnpm build && pnpm start

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
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
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
Expand All @@ -125,11 +125,11 @@ you can experiment with the default Turbopack + `serverExternalPackages` if you

**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
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.
- **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
Expand Down
2 changes: 1 addition & 1 deletion examples/harnesses/pi-agent-harness/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
// The pi coding-agent SDK is a heavy Node-only chain: it spawns bash, reads
// The Pi coding-agent SDK is a heavy Node-only chain: it spawns bash, reads
// the filesystem, loads native terminal helpers via dynamic require, 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.
Expand Down
14 changes: 10 additions & 4 deletions examples/harnesses/pi-agent-harness/scripts/launch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,19 @@ async function chooseWorkspace() {
if (fromArg) {
if (fromArg.startsWith("-")) {
console.error(`launch.mjs: "${fromArg}" looks like a flag, not a path.`);
console.error('Pass the workspace after a space-separated "--", e.g.: pnpm dev -- /absolute/path');
console.error(
'Pass the workspace after a space-separated "--", e.g.: pnpm dev -- /absolute/path',
);
process.exit(1);
}
return fromArg;
}
if (process.env.PI_AGENT_CWD) return process.env.PI_AGENT_CWD;
if (stdin.isTTY) {
const rl = createInterface({ input: stdin, output: stdout });
const answer = (await rl.question(`\nWorkspace the agent may read/run/edit in [${process.cwd()}]: `)).trim();
const answer = (
await rl.question(`\nWorkspace the agent may read/run/edit in [${process.cwd()}]: `)
).trim();
rl.close();
return answer || process.cwd();
}
Expand All @@ -52,8 +56,10 @@ if (!existsSync(workspace) || !statSync(workspace).isDirectory()) {
process.exit(1);
}

console.log(`\n 🛠 pi agent workspace: ${workspace}`);
console.log(" read / bash / edit / write act here, and bash can escape it (see README → Security)\n");
console.log(`\n 🛠 Pi agent workspace: ${workspace}`);
console.log(
" read / bash / edit / write act here, and bash can escape it (see README → Security)\n",
);

const child = spawn("next", NEXT_ARGS[mode], {
stdio: "inherit",
Expand Down
36 changes: 26 additions & 10 deletions examples/harnesses/pi-agent-harness/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { NextRequest } from "next/server";
import { abortSession, getOrCreateSession } from "@/lib/pi-session";
import type { NextRequest } from "next/server";

// The pi SDK spawns bash, reads the filesystem, and talks to model providers —
// The Pi SDK spawns bash, reads the filesystem, and talks to model providers —
// none of which works on the edge runtime.
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand All @@ -26,10 +26,12 @@ function ndjsonChunk(delta: Record<string, unknown>, finishReason: string | null

// pi's reasoning and tool executions are surfaced as OpenAI `tool_calls`, which
// OpenUI renders as cards inside the collapsible "behind the scenes" section —
// the web equivalent of the pi CLI's thinking/tool states. Both pieces of a
// the web equivalent of the Pi CLI's thinking/tool states. Both pieces of a
// tool_call (start = id+name, args = streamed arguments) reuse the same `index`.
function toolStartChunk(index: number, id: string, name: string, args = ""): string {
return ndjsonChunk({ tool_calls: [{ index, id, type: "function", function: { name, arguments: args } }] });
return ndjsonChunk({
tool_calls: [{ index, id, type: "function", function: { name, arguments: args } }],
});
}
function toolArgsChunk(index: number, argsDelta: string): string {
return ndjsonChunk({ tool_calls: [{ index, function: { arguments: argsDelta } }] });
Expand All @@ -47,7 +49,9 @@ function extractText(content: unknown): string {
if (Array.isArray(content)) {
return content
.map((part) =>
part && typeof part === "object" && "text" in part ? String((part as { text: unknown }).text) : "",
part && typeof part === "object" && "text" in part
? String((part as { text: unknown }).text)
: "",
)
.join("");
}
Expand All @@ -59,15 +63,18 @@ export async function POST(req: NextRequest) {
const conversationId = req.headers.get("x-conversation-id") || crypto.randomUUID();
const cwd = process.env.PI_AGENT_CWD || process.cwd();

// The frontend re-sends the full thread, but pi keeps its own transcript, so
// The frontend re-sends the full thread, but Pi keeps its own transcript, so
// we only feed it the newest user turn.
const lastUser = [...(body.messages ?? [])].reverse().find((m) => m.role === "user");
const userText = extractText(lastUser?.content);

let session: Awaited<ReturnType<typeof getOrCreateSession>>["session"];
let modelFallbackMessage: string | undefined;
try {
const entry = await getOrCreateSession(conversationId, { cwd, systemPrompt: body.systemPrompt });
const entry = await getOrCreateSession(conversationId, {
cwd,
systemPrompt: body.systemPrompt,
});
session = entry.session;
modelFallbackMessage = entry.modelFallbackMessage;
} catch (err) {
Expand All @@ -84,7 +91,9 @@ export async function POST(req: NextRequest) {
// interleave token streams and throw "already processing", so refuse politely.
const busy =
ndjsonChunk({ role: "assistant" }) +
ndjsonChunk({ content: "_Still responding to your previous message — please wait for it to finish._" }) +
ndjsonChunk({
content: "_Still responding to your previous message — please wait for it to finish._",
}) +
ndjsonChunk({}, "stop");
return new Response(busy, {
headers: {
Expand Down Expand Up @@ -156,7 +165,14 @@ export async function POST(req: NextRequest) {
}
} else if (event.type === "tool_execution_start") {
// Show each tool run (read/bash/edit/write …) and its input.
enqueue(toolStartChunk(indexFor(event.toolCallId), event.toolCallId, event.toolName, safeArgs(event.args)));
enqueue(
toolStartChunk(
indexFor(event.toolCallId),
event.toolCallId,
event.toolName,
safeArgs(event.args),
),
);
}
});

Expand All @@ -172,7 +188,7 @@ export async function POST(req: NextRequest) {
await session.prompt(userText);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
enqueue(ndjsonChunk({ content: `\n\n**pi error:** ${message}` }));
enqueue(ndjsonChunk({ content: `\n\n**Pi error:** ${message}` }));
} finally {
unsubscribe();
req.signal.removeEventListener("abort", onAbort);
Expand Down
2 changes: 1 addition & 1 deletion examples/harnesses/pi-agent-harness/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import "./globals.css";

export const metadata: Metadata = {
title: "OpenUI Agent Harness",
description: "Generative UI agent harness powered by the pi coding agent",
description: "Generative UI agent harness powered by the Pi coding agent",
};

export default function RootLayout({
Expand Down
Loading
Loading