diff --git a/docs/content/docs/openui-lang/examples/harnesses/pi-agent-harness.mdx b/docs/content/docs/openui-lang/examples/harnesses/pi-agent-harness.mdx index 4582ba833..fa422d7ce 100644 --- a/docs/content/docs/openui-lang/examples/harnesses/pi-agent-harness.mdx +++ b/docs/content/docs/openui-lang/examples/harnesses/pi-agent-harness.mdx @@ -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 ``. 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. @@ -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 `` 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 @@ -45,7 +45,7 @@ import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-li const systemPrompt = openuiLibrary.prompt(openuiPromptOptions); ({ id: crypto.randomUUID(), title: "New chat", createdAt: Date.now() })} processMessage={async ({ threadId, messages, abortController }) => fetch("/api/chat", { @@ -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 @@ -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 })); @@ -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 @@ -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 # 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 @@ -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 ``` diff --git a/examples/harnesses/pi-agent-harness/README.md b/examples/harnesses/pi-agent-harness/README.md index 3d7a3c063..8e3bb842a 100644 --- a/examples/harnesses/pi-agent-harness/README.md +++ b/examples/harnesses/pi-agent-harness/README.md @@ -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 @@ -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 ``` @@ -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. @@ -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 @@ -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 @@ -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 diff --git a/examples/harnesses/pi-agent-harness/next.config.ts b/examples/harnesses/pi-agent-harness/next.config.ts index eb4abf1c3..ba0c4e2a8 100644 --- a/examples/harnesses/pi-agent-harness/next.config.ts +++ b/examples/harnesses/pi-agent-harness/next.config.ts @@ -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. diff --git a/examples/harnesses/pi-agent-harness/scripts/launch.mjs b/examples/harnesses/pi-agent-harness/scripts/launch.mjs index e867f398c..f3b723ef7 100644 --- a/examples/harnesses/pi-agent-harness/scripts/launch.mjs +++ b/examples/harnesses/pi-agent-harness/scripts/launch.mjs @@ -30,7 +30,9 @@ 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; @@ -38,7 +40,9 @@ async function chooseWorkspace() { 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(); } @@ -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", diff --git a/examples/harnesses/pi-agent-harness/src/app/api/chat/route.ts b/examples/harnesses/pi-agent-harness/src/app/api/chat/route.ts index ab12a45e3..56a04b677 100644 --- a/examples/harnesses/pi-agent-harness/src/app/api/chat/route.ts +++ b/examples/harnesses/pi-agent-harness/src/app/api/chat/route.ts @@ -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"; @@ -26,10 +26,12 @@ function ndjsonChunk(delta: Record, 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 } }] }); @@ -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(""); } @@ -59,7 +63,7 @@ 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); @@ -67,7 +71,10 @@ export async function POST(req: NextRequest) { let session: Awaited>["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) { @@ -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: { @@ -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), + ), + ); } }); @@ -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); diff --git a/examples/harnesses/pi-agent-harness/src/app/layout.tsx b/examples/harnesses/pi-agent-harness/src/app/layout.tsx index 8c5523230..37789922e 100644 --- a/examples/harnesses/pi-agent-harness/src/app/layout.tsx +++ b/examples/harnesses/pi-agent-harness/src/app/layout.tsx @@ -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({ diff --git a/examples/harnesses/pi-agent-harness/src/app/page.tsx b/examples/harnesses/pi-agent-harness/src/app/page.tsx index e77299607..7e3d49e01 100644 --- a/examples/harnesses/pi-agent-harness/src/app/page.tsx +++ b/examples/harnesses/pi-agent-harness/src/app/page.tsx @@ -13,12 +13,15 @@ export default function Home() {
{ const content = (firstMessage as { content?: unknown }).content; - const title = typeof content === "string" && content.trim() ? content.trim().slice(0, 50) : "New chat"; + const title = + typeof content === "string" && content.trim() + ? content.trim().slice(0, 50) + : "New chat"; return { id: crypto.randomUUID(), title, createdAt: Date.now() }; }} processMessage={async ({ threadId, messages, abortController }) => { @@ -26,7 +29,7 @@ export default function Home() { method: "POST", headers: { "Content-Type": "application/json", - // Map each chat thread to its own persistent pi AgentSession. + // Map each chat thread to its own persistent Pi AgentSession. "x-conversation-id": threadId, }, body: JSON.stringify({ diff --git a/examples/harnesses/pi-agent-harness/src/lib/pi-session.ts b/examples/harnesses/pi-agent-harness/src/lib/pi-session.ts index a51f61f95..a800e8ff8 100644 --- a/examples/harnesses/pi-agent-harness/src/lib/pi-session.ts +++ b/examples/harnesses/pi-agent-harness/src/lib/pi-session.ts @@ -1,8 +1,8 @@ /** - * Server-only registry of pi `AgentSession`s, one per chat thread. + * Server-only registry of Pi `AgentSession`s, one per chat thread. * * The OpenUI frontend is stateless per request (it re-sends the whole thread - * each turn), but the pi SDK keeps its own transcript and only wants the newest + * each turn), but the Pi SDK keeps its own transcript and only wants the newest * user turn via `session.prompt(text)`. So we key a persistent `AgentSession` * by the frontend's per-thread conversation id and reuse it across turns to * preserve context. @@ -96,15 +96,19 @@ function toolOptions(): { tools?: string[] } { return {}; } -async function createSession(cwd: string, systemPrompt: string | undefined): Promise { - const { createAgentSession, DefaultResourceLoader, getAgentDir, SettingsManager } = await loadSdk(); +async function createSession( + cwd: string, + systemPrompt: string | undefined, +): Promise { + const { createAgentSession, DefaultResourceLoader, getAgentDir, SettingsManager } = + await loadSdk(); evictOldestIfFull(); const agentDir = getAgentDir(); const settingsManager = SettingsManager.create(cwd, agentDir); - // Inject the OpenUI Lang instructions via appendSystemPrompt so the pi model + // Inject the OpenUI Lang instructions via appendSystemPrompt so the Pi model // emits generative UI markup. createAgentSession only auto-reloads the loader // it creates itself, so a custom loader must be reloaded here. const resourceLoader = new DefaultResourceLoader({