Skip to content

Repository files navigation

Pi Outpost

CI Coverage License: MIT Node

A web chat UI for the pi coding agent — run it as a standalone app, or embed it as a Shadow-DOM-isolated widget inside any web app. Built directly on pi's SDK.

A Node server runs the agent — a pi AgentSession in its own process, or a supervised pi --mode rpc child — and bridges it to a React chat UI over WebSocket: streaming responses, collapsible thinking blocks, live tool-execution cards (bash, edit, …), steering while the agent runs, and abort.

pi-outpost, light theme pi-outpost, dark theme

Contents

Features

  • Streaming chat (markdown, thinking blocks, mermaid diagrams, inline images and workspace file links)
  • Tool execution cards with live output — hide them behind a toggle when they drown the conversation. Results are rendered by what they are, not by which tool produced them: a git diff becomes per-file diff blocks with open and history beside each path, a code search becomes hits grouped by file, an edit or write becomes the diff it applied — and anything unrecognized stays the raw output, one keystroke away under every specialized view
  • Steer / follow-up while streaming, abort
  • Model + thinking-level selectors
  • First-run setup in the browser: no credentials, no cryptic failure — paste an API key, or declare your own OpenAI-compatible endpoint (see Model credentials)
  • Sessions: list, resume, rename, delete, and full-text search across saved transcripts
  • Conversation tree: edit a past message to re-ask it, and the old exchange stays reachable as a branch you can navigate back to
  • File browser: lazy-loaded tree, full-size viewer (syntax-highlighted, Markdown rendered) and an editor with save inside the writable zone — all confined to the same root the agent's own tools can see; entries outside sandbox.writableRoot render dimmed, and truncated entries reveal their complete name on hover. Create a file or folder from the tree itself: + on a writable directory opens an input where the file will land (a trailing / makes it a folder), and a new file opens straight into the editor. Files can also be opened with the operating system's associated application, renamed, or deleted after confirmation. Drag a writable file onto a writable directory to move it, or drag a read-only file there to copy it
  • PDF: select one in the tree and it renders in the viewer (pages, zoom, keyboard paging); the agent reads its text and tables through a pdf_extract tool — no shell, no external binary, no OCR
  • Office documents: docx_extract, xlsx_extract and pptx_extract give the agent Word text and tables, one markdown table per spreadsheet sheet (values rendered from their number format), and slide structure with speaker notes. Same rules as pdf_extract — available wherever read is, never behind allowBash, confined to the sandbox root, each with its own size ceiling. Every extractor also takes output_path: write the whole document to a workspace file and get a summary back, instead of paging through it and then writing it out, which spends the context twice
  • Structured results: a tool can hand back data — a graph, a sequence, a table — and the UI renders it natively instead of showing text that describes a picture nobody can check. When the document names a target it becomes an approval gate: someone is agreeing to a change an external authority will then apply. Versioned schema, a conformance suite, and a standalone validator a producer elsewhere can run against its own output; the agent gets a present_structure tool and a bundled skill
  • Agent runtime: embedded pi SDK by default, or a supervised pi --mode rpc child process (see agentRuntime) — same browser protocol either way
  • In-browser sandbox settings: tweak root, writable root, write and bash permissions from the gear menu — no config file edit or server restart needed
  • Attachments: drop or paste images and text files into the composer; the file you are previewing attaches itself as an @path reference, so the agent reads it on demand instead of the prompt carrying its content. Dropping a PDF, .docx, .xlsx or .pptx uploads it into the workspace and attaches the path — which is what the extraction tools take, and why a document is no longer refused as oversized text or as an unsupported binary
  • Git: uncommitted-change badges in the tree, per-file diffs in the viewer, log and commit inspection, and a per-file history graph (renames followed) that diffs the file between any two revisions — the working tree included. Commit rows truncate to keep the graph dense; hovering one gives back the whole subject
  • Session cost: tokens and price per turn in the model bar, and a session-analysis panel behind it — tokens/cost charted across turns, tool calls ranked by output size or failure, requests ranked by what they cost, every row jumping back to the message that produced it
  • Slash commands with autocompletion (/ in the composer: extension commands, prompt templates, skills)
  • File mentions with autocompletion (@ in the composer: recursive name search over the browser root, inserts the relative path)
  • Extension "Custom UI" support: dialogs, notifications, status/widgets, editor prefill (see below)
  • Standalone mode: own config dir, file sandbox, branding (see below)
  • Embeddable widget (@pi-outpost/embed): mount into any web app, isolated via Shadow DOM (see below)

Quick start

Requirements: Node ≥ 24 (what the pi SDK itself requires), and model credentials. You do not need pi installed — its SDK is bundled here.

Run it

npx pi-outpost init   # writes a starter pi-outpost.config.json here
npx pi-outpost        # serves the UI on http://127.0.0.1:3141/

Open the UI with no credentials and it asks for them: pick a provider and paste an API key, or declare an OpenAI-compatible endpoint of your own (see Model credentials). Nothing to restart — the chat is usable as soon as you save.

pi-outpost never starts without a configuration file: the agent's working directory, its tools and its sandbox are decided there, and guessing them from whatever directory you happen to be standing in is not a decision anyone wants made for them. init writes the safe version of that file (read-only, no bash) for you to open up as needed.

Develop against the repository

npm install
npm run dev
  • Web UI: http://localhost:5173 (Vite dev server, proxies /ws, /branding, /health to the agent server)
  • Agent server: ws://127.0.0.1:3141/ws

npm run dev passes the repository's committed pi-outpost.config.dev.json — the same code path, the same rule, no special case for developers.

Tests

npm run test --workspace server        # integration tests: no model auth needed, no tokens spent
npm run test --workspace ui            # component unit tests (vitest, jsdom, testing-library)
npm run test:live --workspace server   # drives real agent turns (needs model auth, costs tokens)

Server integration tests boot a real server against a throwaway workspace (isolated agentDir — your sessions and extensions are never touched) and talk to it over HTTP/WebSocket. See server/test/README.md.

UI component tests run under vitest with jsdom and @testing-library/react, covering components in ui/src/components/ and utilities in ui/src/util/. They need no model auth and cost no tokens.

Security note: the server binds to 127.0.0.1 and validates the WebSocket Origin header. The agent has bash/edit/write tools — never expose this server on a network without the sandbox config below and an auth token: set server.token (or the PI_OUTPOST_TOKEN env variable, which wins) to a long random secret, e.g. openssl rand -hex 32. Binding off loopback without one is now refused, not merely discouraged: the WebSocket accepts connections with no Origin header (a local process already has shell access, so the check would be theatre), and with no token every request is valid — so --host 0.0.0.0 alone would hand the agent to anything that can route to the host. Clients authenticate by opening http://host:3141/?token=<secret> once (stored locally, stripped from the URL) or via the embed widget's token option. Use a reverse proxy or Tailscale for transport encryption.

Production (single process)

npm run start

Builds the web UI once (web/dist) and starts one Node process that serves the UI, /ws, /branding, and /health together on server.port (default 3141) — nothing else to run or keep track of. Point a process manager (systemd, pm2, Docker CMD, …) at this one command; there's no separate dev server to start or stop.

Unlike npm run dev, this reads your configuration (./pi-outpost.config.json, or any of the locations below) — not the repository's dev config. With none, it refuses to start and says so.

Rebuild (npm run build --workspace web) and restart after any UI change — this mode has no hot reload, unlike npm run dev above.

Need to distribute a version that doesn't require Node.js installed at all (e.g. a Windows .exe for non-technical users)? See docs/sea-packaging.md.

Command line

pi-outpost [options]          start the server
pi-outpost init [options]     write a starter configuration file
pi-outpost config [options]   print the configuration that would be used, and where it came from
pi-outpost login --provider <name>
                              store an API key in <agentDir>/auth.json

Upgrading from a pre-0.1.0 clone? Three behaviours changed. The server now refuses to start without a configuration file (it used to fall back to a plain local pi: your launch directory as workspace, full toolset, bash enabled) — run pi-outpost init. PI_OUTPOST_PORT/PORT now override server.port instead of being overridden by it, in line with PI_OUTPOST_TOKEN, which always won. And PI_CWD is now PI_OUTPOST_CWD.

Flag Effect
--config <path> Configuration file to use
--profile <name> Use <user config dir>/profiles/<name>.json
--cwd <dir> Directory the agent works in
--agent-dir <dir> pi config/session store (default ~/.pi/agent)
login --provider <name> Store a key for that provider (prompted, or read from stdin — never a flag)
--port <n> / --host <addr> Where to listen (default 127.0.0.1:3141)
-h, --help / -v, --version
init --global Write to the user config directory instead of ./
init --force Overwrite an existing file

There is deliberately no --token flag: a secret on the command line is readable by anyone who can list processes. Use PI_OUTPOST_TOKEN or the file's server.token.

Model credentials

Credentials come from either provider environment variables (ANTHROPIC_API_KEY, …) or an auth.json in the agent directory<agentDir>/auth.json, which is ~/.pi/agent/auth.json unless your config names its own agentDir. Point that key at an isolated directory (as the sandbox advice below suggests) and it starts empty: that is the case this section exists for.

Three ways to fill it:

The UI With no usable model, pi-outpost shows a setup screen instead of a chat that could only fail. Paste a key, or declare your own endpoint. It writes <agentDir>/auth.json and starts working immediately — no restart.
pi-outpost login For headless servers, where no browser will ever open the UI:
pi-outpost login --provider anthropic (prompts, not echoed)
echo "$KEY" | pi-outpost login --provider anthropic (scripted)
The key has no flag on purpose — argv is readable by anyone who can list processes, the same reason there is no --token.
Environment export ANTHROPIC_API_KEY=… before starting. Nothing is written to disk.

An OpenAI-compatible endpoint of your own

A corporate gateway, vLLM, SGLang, Ollama, LM Studio — anything speaking the OpenAI API. Declare it from the UI's setup screen (name, base URL, key, model id) and it is written to <agentDir>/models.json in pi's own format, so it survives restarts and any pi process sharing that directory sees it.

The two compatibility checkboxes on that form are not a detail to skip. Many OpenAI-compatible servers reject the developer role and the reasoning_effort field that pi sends to reasoning-capable models — and when they do, every turn fails, with an error that never names the cause. Unchecking them makes pi send a plain system message and drop reasoning_effort.

Behind a TLS-inspecting proxy

A corporate proxy that re-signs certificates with an internal CA makes Node reject the chain, which surfaces as a bare fetch failed. Trust the CA, and everything verifies normally:

export NODE_EXTRA_CA_CERTS=/path/to/corp-ca.pem

pi-outpost detects that failure and names this variable rather than leaving you to guess. There is deliberately no configuration key that disables TLS verification: it would disable it for every outbound connection — including the one carrying your API key — and a flag in a file gets copied between machines and outlives the reason it was added. NODE_TLS_REJECT_UNAUTHORIZED=0 remains available to whoever sets it knowingly, at launch, for as long as that shell lives.

Standalone configuration

The server reads the first of these that exists, and only that one — configurations are never merged, so the file you are reading is the configuration that is running:

  1. --config <path>
  2. --profile <name><user config dir>/profiles/<name>.json
  3. $PI_OUTPOST_CONFIG
  4. $PI_OUTPOST_PROFILE<user config dir>/profiles/<name>.json
  5. ./pi-outpost.config.json (the directory you launch from)
  6. <user config dir>/config.json

<user config dir> is $XDG_CONFIG_HOME/pi-outpost, or ~/.config/pi-outpost. A file you name explicitly must exist; the two implicit locations are simply skipped. Found nothing? The server refuses to start and tells you to run pi-outpost init.

Not sure which file won, or why a setting has the value it has? pi-outpost config prints the resolved configuration and the file it came from, without starting anything. Every start also logs the file it loaded and the sandbox it is actually enforcing.

Profiles. --profile work (or $PI_OUTPOST_PROFILE) reads <user config dir>/profiles/work.json. A profile is an ordinary config file — same keys, same rules — so pi-outpost --profile work from anywhere gives you the setup you configured once.

Precedence. For any setting that appears in more than one place: flag > environment variable > config file > default. Environment variables: PI_OUTPOST_PORT (falling back to PORT, which platforms inject), PI_OUTPOST_HOST, PI_OUTPOST_CWD, PI_OUTPOST_AGENT_DIR, PI_OUTPOST_TOKEN, PI_OFFLINE (the pi SDK's own variable, honoured here so one spelling covers both layers).

One exception, and it is deliberate: a sandbox that grants write or bash, but names no sandbox.root, refuses a --cwd/PI_OUTPOST_CWD override. Such a sandbox falls back to cwd, so an inherited variable (a shell profile, a CI job, a compose file) could otherwise turn "write inside my project" into "write inside /" without touching the file that granted it. Name the root, and the grant says what it covers. A read-only sandbox has no such hazard and simply follows the workspace.

See pi-outpost.config.example.json.

Key Effect
cwd Agent working directory
agentDir Own config dir (auth, models, settings, sessions) — fully separate from ~/.pi/agent. It starts with no credentials: see Model credentials
sandbox.root Read-only zone: read/ls/grep/find are confined to this directory, symlinks resolved. Defaults to cwd if omitted
sandbox.allowWrite Adds edit/write, confined to sandbox.writableRoot (or the whole root if unset) (default false)
sandbox.writableRoot Read-write zone: subdirectory of root that edit/write are further confined to. Must be inside root. Defaults to root itself
sandbox.allowBash Adds bash — not path-confined, explicit opt-in (default false)
agentRuntime Which runtime serves the browser: { "mode": "embedded" } (default) keeps the pi SDK session in this process; { "mode": "rpc", "executable": "pi", "args": [] } supervises a pi --mode rpc child. pi-outpost appends --mode rpc and derives --session-dir itself, so args may not contain either, nor --tools/--system-prompt (those come from tools/systemPrompt), nor any flag that would make the child print something and exit. A failed executable stops startup — there is no silent fallback to embedded. sandbox cannot be combined with rpc and the pair is refused at load: the sandbox is a replacement toolset this server builds, and a child builds its own
pdf.maxBytes Largest PDF the viewer may load, in bytes (default 26214400 — 25 MB). Every other file keeps the 1 MB limit. The pdf_extract tool refuses a PDF above the same ceiling
docx.maxBytes / xlsx.maxBytes / pptx.maxBytes Same ceiling, per format, for docx_extract / xlsx_extract / pptx_extract
tools Tool allowlist in non-sandbox mode, e.g. ["read","grep","find","ls"]
noExtensions Disable extension discovery entirely
extensionPaths Explicit extension .ts/.mjs files to load (resolved relative to the config file's directory). Loaded via the pi SDK's jiti-based loader — works in dev mode and the npm-published bundle
extensionScripts Extension .mjs files loaded at runtime via import() — works in dev mode and the npm-published bundle. Each file must default-export an ExtensionFactory. Paths are resolved relative to the config file's directory
noSkills Disable skill discovery entirely. Needed for real isolation: even with a custom agentDir, skills also auto-load from ~/.agents/skills (hardcoded to the real home directory) and from .agents/skills walked up from cwd to the git root — neither is scoped by agentDir
skillPaths Explicit skill files or directories to load, in addition to discovery. Loaded even under noSkills — naming a skill and being given nothing is the worse surprise. Paths are resolved relative to the config file's directory
noPromptTemplates Disable prompt template auto-discovery entirely (both agentDir and the project's cwd/.pi/prompts). Relevant when cwd points at a real project: it doubles as a resource-discovery root, so that project's own prompt templates load too unless disabled
promptPaths Explicit prompt template directories to load (in addition to auto-discovered ones). Paths are resolved relative to the config file's directory
allowedModels Restrict the model switcher to these { "provider", "id" } pairs. Without it, every built-in model whose provider has configured auth is listed — often more variants than a given deployment (e.g. an air-gapped internal endpoint) actually serves
systemPrompt / systemPromptFile Replace pi's built-in system prompt entirely (mutually exclusive; systemPromptFile is a path to a text file). Project context files, skills, and appendSystemPrompt are still layered on top
appendSystemPrompt Array of extra paragraphs appended after the (built-in or custom) system prompt
webContext Inject a short web-UI context block into the system prompt so the agent knows its replies render in this UI (markdown, inline images, file links). Default true; set false for tightly curated prompts
offline Never fetch remote model catalogs. On a host that cannot reach them — air-gapped, or behind a proxy that does not route them — that request hangs and stalls every credential change by 20 s. Built-in and models.json providers are unaffected: the catalog only adds metadata for models the SDK already knows. Default false; --offline and PI_OFFLINE also turn it on
server.port Port to listen on (default 3141). --port and PI_OUTPOST_PORT/PORT override it
server.host Host to bind to (default 127.0.0.1 — only change this if you understand the security note above)
server.allowedOrigins Extra exact Origins accepted on the WebSocket (embed the UI as a tab in another app)
server.token Shared secret required on the WebSocket and /branding when set (PI_OUTPOST_TOKEN env overrides). Mandatory in practice when server.host is not loopback
branding title (default "π"), welcome message, accentColor — applied by the web UI
branding.defaultTheme "light" | "dark" | "system" (default) — used when the client has no stored preference
branding.allowThemeToggle Show the theme toggle button (default true). Set false when embedding in a host app that drives the theme itself — see below

Theming

The UI ships with light and dark themes. Precedence: a local pick from the toggle button (persisted in localStorage) or an explicit override (the embed widget's theme option / setTheme(), or a host page's postMessage) beats branding.defaultTheme, which falls back to the OS preference ("system").

Relative paths are resolved against the config file's directory.

Embedding

embed/ publishes @pi-outpost/embed, mounting pi-outpost into any element inside a Shadow DOM — fully isolated from the host app's CSS in both directions, React supplied as a peer dependency (not bundled), everything else (Tailwind, markdown/mermaid/highlight.js, the shared protocol types) compiled into the package.

import { mount } from "@pi-outpost/embed";

const widget = mount(document.getElementById("assistant"), {
  serverUrl: "https://your-pi-outpost-server", // omit for same-origin
  theme: "dark", // optional; falls back to branding.defaultTheme, then "system"
});

widget.setTheme("light"); // change the theme at runtime
widget.unmount(); // tear down the React tree

Build it with npm run build --workspace @pi-outpost/embed (outputs ESM + CJS to embed/dist/, plus a rolled-up .d.ts), then publish embed/ to your own registry.

Two things to configure on the server side regardless of deployment topology:

  • server.allowedOrigins: the widget's WebSocket connection carries the host page's origin (e.g. https://your-app.example.com), not pi-outpost's own — add it explicitly, even same-domain deployments need this (only localhost/127.0.0.1 are trusted automatically).
  • CORS: an origin listed in server.allowedOrigins now receives CORS headers on the HTTP endpoints too (/branding, /health, /files/raw, the static app), with cache variants preserved — so a genuinely cross-origin widget works without a proxy in front. The allowlist is the whole of it: no origin outside it is answered.

A raw iframe (<iframe src="https://your-pi-outpost-server">) still works too, and still honors branding.allowThemeToggle: false plus the host-driven theme channel:

iframeWindow.postMessage({ type: "pi-outpost:set-theme", theme: "light" }, "https://your-pi-outpost-origin")

Extension Custom UI

Extensions using pi's Custom UI (ctx.ui.select/confirm/input/editor/notify/setStatus/setWidget/setTitle/setEditorText) work in the web UI: dialogs render as a modal, notify() as a toast, setStatus() as a header badge, setWidget() above/below the composer. The bridge binds with mode: "rpc", mirroring pi's own RPC-mode protocol — so ctx.hasUI is true and dialogs get real answers, but TUI-only features (custom(), custom footers/headers/editors, terminal input, themes) have no web equivalent and are no-ops, same as RPC mode.

Custom messages (pi.sendMessage() with a customType, see Message and Entry Rendering) show up too, but without the extension's MessageRenderer — that returns a terminal Component, which has no browser equivalent. Instead it falls back to pi's own default look (violet card, markdown-rendered content), with any details payload collapsed behind a toggle (never verbose JSON by default). Messages sent with display: false stay hidden, same as in the TUI.

Architecture

shared/  (protocol types — events, ChatItem, DialogRequest)

ui/  (React components & hooks)       server/  (Fastify + ws)
┌──────────────────────────┐          ┌──────────────────────────┐
│ @pi-outpost/ui exports  │          │ agent runtime boundary   │
│ CopyButton, DiffBlocks, │  /ws     │  ├ embedded AgentSession │
│ ToolCard, useAgent, …   │ ◄───────► │  └ pi --mode rpc child   │
│                          │  JSON    │ events → lean wire       │
└────────┬─────────────────┘          └──────────────────────────┘
         │ import
         ▼
web/  (React + Vite + Tailwind)     embed/  (Shadow-DOM widget)
┌──────────────────────┐            ┌──────────────────────────┐
│ Standalone app       │            │ @pi-outpost/embed         │
│ (mounts UI from ui/) │            │ (mounts UI from ui/)      │
└──────────────────────┘            └──────────────────────────┘

Sessions persist in <agentDir>/sessions/ — reconnecting clients receive the full history (hello message).

Single-tenant by design. One agent runtime, one clients set, and broadcast() sends every event to every socket: two people connected to the same server share one conversation, and the sandbox roots are resolved once at startup. That is deliberate for a personal deployment, and the thing to fix before a shared one — see #4, multi-user support.

License

MIT

About

WebUI for PI

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages