pi Agent Harness
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 (@earendil-works/pi-coding-agent), running its default read / bash / edit / write tools, to <FullScreen>. pi's OpenUI Lang 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.
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):
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():
// 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 });// 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:
} 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:
pnpm dev -- /path/to/your/project # explicit
pnpm dev # prompts for the workspaceThe 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_CWDis a discovery root, not a sandbox:bashcan 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 externalRun the example
From the repo root, install workspace deps once, then run the example pointed at a project:
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/projectOpen 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.