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.

View source on GitHub →

How it connects

PieceFileRole
Frontendsrc/app/page.tsxA single <FullScreen> with streamProtocol={openAIReadableStreamAdapter()}. Generates the OpenUI Lang system prompt and sends it with each turn.
Bridge routesrc/app/api/chat/route.tsDrives a pi AgentSession and re-emits its events as NDJSON OpenAI chunks (delta.content is OpenUI Lang).
Session registrysrc/lib/pi-session.tsOne persistent AgentSession per chat thread, keyed by the x-conversation-id header.
Agent@earendil-works/pi-coding-agentThe 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 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:

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 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.

On this page