@assistant-ui/react-pi
v0.0.25
Published
Pi coding-agent runtime adapter for assistant-ui
Readme
@assistant-ui/react-pi
Pi coding-agent runtime adapter for assistant-ui.
This package lets assistant-ui render and drive Pi-backed threads: streaming assistant/reasoning output, tool calls with live streaming results, mid-run steering and follow-up, per-thread model/thinking controls, the blocking extension UI (Pi's entire human-in-the-loop/approval surface), and a multi-thread thread list.
Package boundary
The package has two entry points:
@assistant-ui/react-pi— browser-safe. The runtime hook, the pure event reducer, the message projection, and the HTTPPiClient. This entry never imports@earendil-works/pi-*; it speaks an RPC-isomorphic, JSON-safe contract (PiClient) over an arbitrary transport.@assistant-ui/react-pi/node— node-only.createPiNodeClient, which drives Pi'sAgentSessionSDK in-process behind a process-singletonPiThreadSupervisor. Only this entry pulls in Pi's Node packages.
HTTP is one implementation of PiClient, not the contract — the runtime hook
never bakes in a transport. You can serve the node client over HTTP/SSE (below),
hand it straight to usePiRuntime in a colocated Electron/main-process setup, or
write any other transport that satisfies PiClient.
Install
npm install @assistant-ui/react-pi @assistant-ui/react
# the node entry drives the Pi SDK on the server:
npm install @earendil-works/pi-coding-agent@earendil-works/pi-coding-agent is an optional peer dependency — browser-only
consumers (those importing a remote PiClient) never pull it in. The ./node
entry needs it.
Quickstart (HTTP/SSE transport)
The two halves of one PiClient contract:
browser server (Node)
───────── ─────────────
usePiRuntime route layer (GET/POST /api/pi/**)
└ createPiHttpClient ──HTTP──▶ createPiNodeClient(...)
(fetch + SSE) └ PiThreadSupervisor → Pi SDK1. Server: the node client
createPiNodeClient returns a PiClient. It owns a process-singleton
supervisor pinned to globalThis, so it survives Next.js dev HMR — create it in
a module-level server file, never per request.
// lib/pi-server.ts (server-only — imported only from route handlers)
import { createPiNodeClient } from "@assistant-ui/react-pi/node";
export const piClient = createPiNodeClient({
workspacePath: process.env.PI_WORKSPACE_PATH ?? process.cwd(),
// agentDir?: override Pi's config dir (default ~/.pi/agent)
// model?: a resolved Pi `Model` to seed new sessions (see Model controls)
});2. Server: the route layer
Expose the PiClient methods over HTTP, and subscribe over SSE. The wire
contract createPiHttpClient expects (relative to baseUrl, default /api/pi):
GET /threads → PiThreadMetadata[]
POST /threads → PiThreadSnapshot (body: create input)
GET /threads/:id → PiThreadSnapshot (read-only snapshot)
PATCH /threads/:id → 204 (body: { title })
POST /threads/:id/messages → 204 (body: { input })
POST /threads/:id/cancel → 204
GET /models → PiModelInfo[]
POST /threads/:id/model → 204 (body: { provider, modelId })
POST /threads/:id/thinking → 204 (body: { level })
POST /threads/:id/archive → 204
POST /threads/:id/unarchive → 204
DELETE /threads/:id → 204 (also when the thread is already gone)
POST /threads/:id/host-ui → 204 (body: { response })
GET /threads/:id/events → SSE of PiClientEvent (?snapshot=false skips initial snapshot)The events route must stream piClient.subscribe(threadId, …) and unsubscribe on
request abort. A browser disconnect must not abort the run — see Reconnect.
See examples/with-pi/app/api/pi/** for a
complete Next.js App Router implementation.
3. Browser: the runtime
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { createPiHttpClient, usePiRuntime } from "@assistant-ui/react-pi";
import { useMemo } from "react";
export function PiRuntimeProvider({ children }: { children: React.ReactNode }) {
const client = useMemo(() => createPiHttpClient(), []); // baseUrl defaults to /api/pi
const runtime = usePiRuntime({ client /*, workspacePath, includeArchived */ });
return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
}usePiRuntime requires options.client — there is no implicit transport. Drop
the provider above any assistant-ui thread UI (Thread, ThreadList, …).
Assistant Cloud
Pass cloud to back the thread list with Assistant Cloud instead of the Pi server's thread list. cloud is the only switch: unlike the runtimes whose list lives in the browser, NEXT_PUBLIC_ASSISTANT_BASE_URL alone keeps the Pi server's list, so setting it never moves an existing app's threads.
const runtime = usePiRuntime({ client, cloud });Each cloud thread maps to a Pi thread through its external id: a new thread creates a Pi thread in workspacePath, and deleting the thread deletes its Pi thread first, where a Pi thread that is already gone does not block it. The list holds every thread of the cloud project, archived ones in their own section, so workspacePath only places new threads and includeArchived is not used. A cloud thread that no Pi runtime created has no Pi thread: it opens empty, and sending in it rejects. With cloud, threadId and initialThreadId take cloud thread ids, so a link that names a Pi thread id no longer opens it. Renaming and archiving change the cloud thread only; the Pi thread keeps its own title and archive state, which is what the list shows again if cloud is removed.
Environment / model resolution
A Pi session needs a model and credentials. Resolution mirrors Pi's own
createAgentSession: an explicit PI_PROVIDER + PI_MODEL_ID wins, otherwise Pi
falls back to its configured default (settings.json's defaultProvider /
defaultModel). A user who is authenticated with pi and has a default model
picked needs no env at all.
| Variable | Purpose |
| --------------------- | -------------------------------------------------------------------------------------------- |
| PI_PROVIDER | Override provider for new sessions (e.g. anthropic). |
| PI_MODEL_ID | Override model id for new sessions. |
| PI_WORKSPACE_PATH | Working directory the agent reads/writes/runs shell commands in. Point it at a scratch dir. |
| PI_CODING_AGENT_DIR | Pi's agent config dir (~/.pi/agent by default). |
The ./node entry takes a resolved Pi Model via createPiNodeClient({ model })
to seed new sessions. Use Pi's own ModelRegistry / SettingsManager /
AuthStorage on the server to resolve it. See
examples/with-pi/lib/pi-server.ts.
Model & thinking controls
Per-thread, surfaced through usePiRuntimeExtras():
import { usePiRuntimeExtras } from "@assistant-ui/react-pi";
const { readiness, contextUsage, setModel, setThinkingLevel } = usePiRuntimeExtras();
// readiness: "ready" | "missing-model" | "missing-credentials" | "unavailable-model"
// await setModel({ provider, modelId });
// await setThinkingLevel("off" | "minimal" | "low" | "medium" | "high" | "xhigh");getAvailableModels() on the client returns the catalog (auth-configured models
first, falling back to the full built-in list). setThinkingLevel is clamped by
Pi to the model's supported levels; the UI reflects the effective level from the
next snapshot/event. contextUsage ({ tokens, contextWindow, percent })
answers "am I about to auto-compact?" and is available even for cold threads.
readiness gates sending — block the composer and show the readiness.message
(a concrete next step, e.g. "Authenticate with pi, then restart the server.")
when it is not "ready".
Composer run semantics
Pi's defining interaction is mid-run steering, and a plain prompt() while
streaming throws. The runtime exposes Pi's native queue to assistant-ui
(capabilities.queue), so the standard composer keeps accepting input during a
run and derives the right behavior:
| State / action | Behavior |
| ------------------------------ | ---------------------------------------------------- |
| idle + submit | prompt() |
| running + Enter | follow-up (streamingBehavior: "followUp") — queued |
| running + Cmd/Ctrl+Shift+Enter | steer (streamingBehavior: "steer") |
To force steer from your own composer, send with steer: true or set
message.runConfig.custom.streamingBehavior = "steer". While running, an
omitted behavior defaults to "followUp".
Queued messages live in Pi's queue, not the transcript — Pi appends the
user message only when the queue flushes. The runtime mirrors them as queue
items (s.composer.queue, renderable with ComposerPrimitive.Queue) and on
the thread metadata's queuedMessages. Pi supports clearing the whole queue
(clearQueue() on the client / usePiRuntimeExtras().clearQueue(), which
resolves with the cleared text so it can be restored into the composer), but
no per-item remove/promote (see Known limitations).
Host-UI requests (the approval surface)
Pi has no built-in permission system — the only human-in-the-loop mechanism is
extensions/tools calling ctx.ui.confirm / select / input / editor. This package
implements and binds the ExtensionUIContext on the server and routes the four
blocking dialogs to the UI, split by causality:
- Tool-associated (a dialog raised while exactly one tool is executing) → rendered as the tool call's
ToolCallMessagePart.approvaland answered through the runtime'sonRespondToToolApproval.confirmasks for a decision,selectoffers one option per choice (option ids are the choice indexes), andinput/editorask for a text answer, so the default tool fallback renders the matching controls. A custom tool UI answers withrespondToApproval({ approved }),respondToApproval({ optionId, approved: true })orrespondToApproval({ text }), andapproved: falsedismisses aselect,inputoreditorrequest; those requests are projecteddismissible, so the default tool fallback offers a Dismiss control for them. The fallback does not show aninputplaceholder or aneditorprefill; a custom tool UI reads them fromusePiRuntimeExtras().allHostUiRequests. - Free-standing (extension commands, any request raised while multiple tools are in flight, and any request the tool call's approval cannot answer, such as a
selectwithout choices or a kind this client does not know) → a side channel:
import { usePiHostUiRequests } from "@assistant-ui/react-pi";
const { requests, respond } = usePiHostUiRequests();
// confirm: respond({ requestId, confirmed: boolean })
// select/input/editor: respond({ requestId, value: string })
// or respond({ requestId, dismissed: true })Pi's UI requests carry no toolCallId, so causality is only inferred when a
single tool is executing; otherwise requests stay on the side channel. Pending
requests are tracked on the server record (not the SSE connection), so a
reconnecting client still sees them. Unsupported ExtensionUIContext methods
degrade rather than crash.
Reconnect semantics
- The supervisor keeps the runtime alive across browser disconnects — only an
explicit
cancelRunor process exit stops a run. A dropped SSE never aborts. - HTTP reconnects receive an authoritative
snapshotevent, then live events.includeSnapshot: falseskips only the initial snapshot. Reconnect snapshots recover missed messages and thread status without event replay. - Browser subscribers share the long-lived SSE connection. A subscriber joining late receives the current snapshot first; when the cached snapshot is behind, one short-lived snapshot request is shared by all subscribers waiting for it.
- Cold/historical threads load via a read-only session-file snapshot — opening
a thread to read it does not spin up a live
AgentSession. A live runtime is created only when you send, cancel, change model/thinking, answer host UI, or explicitly subscribe to live events.
Selector hooks
The runtime keeps high-frequency transcript state separate from low-frequency metadata, so metadata controls don't rerender on every token:
usePiRuntimeExtras()—status,readiness,contextUsage,queue,compaction,retry,lastError, host-UI requests, and thecancel/refresh/clearQueue/setModel/setThinkingLevel/respondTo*/resumeToolCallactions.usePiSession()— the currentPiThreadMetadata(ornull).usePiThreadState(selector?)— the raw reducer state, optionally selected.usePiHostUiRequests()— free-standing host-UI requests + a responder.
Known limitations (MVP)
- No RPC-subprocess transport. The SDK-in-process node client assumes one long-lived Node process; it does not survive serverless/edge. The contract is RPC-isomorphic so a subprocess/remote transport can drop in later.
- No durable event replay / backpressure / version negotiation. Recovery is snapshot-first only.
- Idle threads are not auto-followed on view. Opening a thread is a cheap
read-only snapshot; the runtime auto-subscribes to live events only when the
loaded snapshot reports the thread as running (or when you send). A run that
starts elsewhere after you opened an idle view shows up on the next
refresh, not live. (
controller.connect()exists for explicit always-live subscription.) - The node host's catalog is process-local and in-memory. It caches
SessionManager.list()and tracks archive state for the running process; nothing is persisted across restarts (no last-selected workspace, drafts, labels, or search index). Workspace is just aworkspacePathstring on the client — the UI for choosing one (text field, directory picker, …) is the consuming app's concern, not this package's. - No fork/clone/navigate or per-item queue editing. The reducer/projection
preserve the underlying data — tree linkage (
parentSessionPath), queued messages (queuedMessages), and compaction state — so a consumer can build those surfaces, but the client exposes no methods to fork a session, navigate the tree, or reorder the queue. Queue items mirror Pi's server-side queue: enqueueing works (that's how mid-run follow-up/steer is sent) and clearing all works (clearQueue, mirroring Pi's only queue mutation), but per-item steer/remove affordances are no-ops — Pi has no such API. - Attachments are image-passthrough only. Non-image input parts aren't converted into Pi user content.
- Model/thinking are the only runtime-config actions. There are no provider/auth/credential or default-model/settings methods on the client — manage those with Pi's own SDK on the server. Skills/extensions/slash-commands aren't surfaced.
