A self-hosted web interface for the pi coding agent — run Pi in your browser from your own machine or server, install Pi Outpost as a desktop app, or embed it as a Shadow-DOM-isolated widget inside another web application.
One Node process runs the agent and serves the interface: streaming responses, live tool cards, a file browser and editor, Git integration, PDF and Office document support, and multiple projects open at once — each with its own sandbox, history, and agent. You control the shape of the sandbox and where everything runs.
- Start in 5 minutes
- How do I…
- What you get
- Model credentials
- Projects
- Settings from the browser
- Configuration file
- Command line
- Staying up to date
- Running it as a service
- Installing it as an app
- Embedding
- Work Plans
- Workspace Outcome
- Development
You need Node ≥ 24 (what the pi SDK itself requires) and an API key for one model provider. You do not need pi installed — its SDK is bundled here.
1. Go to the project you want to work on. The configuration is written here, and the agent starts out confined to this directory.
cd ~/projects/my-app2. Write a starter configuration.
npx pi-outpost initThat gives you a read-only agent — it can read this directory, and nothing else: no writing, no shell.
3. Start it.
npx pi-outpostIt serves on http://127.0.0.1:3141/ and opens the interface in a window of its own — no
tabs, no address bar. --open-in browser puts it in a normal browser tab, --no-open
leaves it closed.
4. Give it a model. With no credentials, the interface asks for them instead of showing a chat that could only fail: pick a provider and paste an API key, or declare an OpenAI-compatible endpoint of your own — a corporate gateway, vLLM, Ollama, LM Studio. Nothing to restart; the chat works as soon as you save.
5. Ask it something. "What does this project do?" is a good first turn — it can read everything under this directory, and you will see it search and open files as it goes.
Then, when you want it to actually change things, open pi-outpost.config.json and widen
the sandbox — or do it from the gear menu, which applies immediately and writes the same
file:
{
"cwd": ".",
"sandbox": { "root": ".", "allowWrite": true, "writableRoot": "./src", "allowBash": false }
}No Node, no npm? Each release carries a single executable per platform under
Releases, server and interface
inside. On macOS and Linux, chmod +x it first — a release download carries no execute
bit. Unsigned, so macOS blocks a fresh download outright and Windows SmartScreen warns on
first launch — both clear in a step; see docs/sea-packaging.md, which
also covers building one yourself with npx pi-outpost build-exe.
pi-outpost never starts without one: 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:
{
"cwd": ".",
"agentRuntime": { "mode": "embedded" },
"sandbox": { "root": ".", "allowWrite": false, "allowBash": false },
"server": { "port": 3141, "host": "127.0.0.1" },
"branding": { "title": "π" }
}Not sure which file is in force, 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.
Security note. The server binds to
127.0.0.1and validates the WebSocketOriginheader. The agent can be given bash, edit and write tools — never expose this server on a network without a sandbox and an auth token: setserver.token(or thePI_OUTPOST_TOKENenvironment variable, which wins) to a long random secret, e.g.openssl rand -hex 32. Binding off loopback without one is refused, not merely discouraged. Clients authenticate by openinghttp://host:3141/?token=<secret>once (stored locally, stripped from the URL) or via the embed widget'stokenoption. Use a reverse proxy or Tailscale for transport encryption.
Task-by-task recipes live in docs/how-to.md — the configuration each
one needs, the command that proves it works, and the caution that goes with it.
- Streaming answers in markdown, with collapsible thinking blocks, mermaid diagrams, math, inline images and clickable workspace file links. A diagram too wide to read in the column is drawn down the page instead, whichever direction its source asked for, and says so — one click puts it back
- Sections an extension appends to an answer render as real markup — a
<details>block of sources folds away, and its classes reach a host page's CSS. Reply text is untrusted whoever wrote it, so all of it is filtered first: scripts, event handlers,javascript:links, styles, frames and forms never reach the page - Tool cards with live output and, for tools that report it, a progress bar. One toggle hides them all when they drown the conversation, and the preference sticks
- Results rendered by what they are, not by which tool produced them: a
git diffbecomes per-file diffs withopenandhistorybeside each path, a search becomes hits grouped by file, an edit becomes the diff it applied — the raw output always one keystroke away - Steer or follow up while the agent is streaming, and abort
- Model and thinking-level selectors; tokens and price per turn, plus a session-analysis panel — cost across turns, tool calls ranked by output size or failure, every row jumping back to the message that produced it
- Sessions: list, resume, rename, delete, and full-text search across saved transcripts
- Conversation tree: edit a past message to re-ask it; the old exchange stays reachable as a branch you can navigate back to
- Slash commands (
/) and file mentions (@) with autocompletion, and a button back to the latest message when you have scrolled up - Attachments: drop or paste images and text files into the composer. A PDF,
.docx,.xlsxor.pptxis uploaded into the workspace and attached as a path, so the agent reads it with its extraction tools instead of the prompt carrying its content
- Several projects open at once, each with its own agent, sandbox, sessions and history — switch between them while work continues in the ones you are not watching, and get a browser notification when a background project needs an answer or is ready for review
- File browser: lazy-loaded tree, syntax-highlighted viewer, Markdown rendering, and an editor that saves inside the writable zone. Create, rename, move, copy, delete, or open a file with the system's own application; anything outside the writable zone is dimmed. Ctrl+F (Cmd+F) finds text in the open file — source, rendered Markdown, or a live edit
- Word export: any text file open in the viewer downloads as a real
.docx. Markdown becomes Word's own structure — heading styles, lists, tables, hyperlinks — LaTeX becomes native Word equations you can edit in the equation editor, and mermaid diagrams travel as vector images that stay sharp, with a raster behind them for readers that do not draw SVG. A figure the document references — beside it, below it or above it — is carried as a picture under the same rule; one that cannot be read keeps its alt text, and an image referenced by an absolute URL is never fetched. Any other text file exports as monospaced lines. It is a download, so a read-only file offers it too, and the writer is fetched only when you first use it - Split view: a Markdown or structured document renders beside the editor, following what you type rather than what was last saved
- Git: uncommitted-change badges in the tree, per-file diffs, log and commit inspection, and a per-file history graph (renames followed) that diffs any two revisions, working tree included. A directory of independently versioned projects works too — each child repository answers for its own files. See Git
- PDF in the viewer (pages, zoom, keyboard paging, Ctrl+F search across the whole document);
the agent reads its text and tables through
pdf_extract— no shell, no external binary, no OCR - Office documents:
docx_extract,xlsx_extractandpptx_extractgive the agent Word text and tables, one markdown table per spreadsheet sheet, and slide structure with speaker notes. Text a document crosses out comes back as~~struck through~~rather than as live content — in a.docxfrom the run's own formatting, in a PDF from the strike the page draws over the glyphs. Each takes anoutput_path, to write the whole document to a file instead of spending the context on it twice. The four extractors are described to the agent only once a document of that kind is named in the conversation — their schemas are a quarter of a session's prompt floor, and most sessions never open one. Named and never called, an extractor goes again when the turn ends; named and used, it stays through the work around that document and is forgotten after five quiet turns. Naming the document again brings it back - Structured results: a tool can hand back data — a graph, a sequence, a table — and the
interface draws it, with an approval gate when the document names a
target. Files that declare the schema open as the diagram they describe, and any diagram exports as a self-contained SVG. A graph with too many boxes to read across is laid out down the page, and the reader can turn it either way. A graph may name the readings it is made for as viewpoints — power, control, safety — which a reader selects and the agent writes one figure for. A project can hold the agent's documents to its own data model — kinds, attributes, enumeration values, the kinds each relationship joins — declared in local profile files, and review them against rules written on top of it; the agent shows those rules back as a register and as one small pattern per rule. Seedocs/structured-exchange.mdanddocs/structured-exchange-project-setup.md - Work Plans: for non-trivial work the agent keeps an explicit hierarchy of objectives, dependencies and verification state beside the conversation
- Workspace Outcome: review plan progress, recorded verification, and changed files from one deterministic workspace view
- First run in the browser asks for credentials instead of failing cryptically: paste an API key, or declare your own OpenAI-compatible endpoint
- Settings from the browser: sandbox permissions and roots, skill and extension directories — persisted to your configuration file, no restart
- A sandbox that decides what the agent can read, write and run, per project
- Installable as an app, and downloadable as a single executable with no Node.js needed
- Embeddable widget (
@pi-outpost/embed) with its own workspace policy - Extension "Custom UI" support: dialogs, notifications, status badges, widgets, editor prefill
- Two agent runtimes behind the same interface: the bundled pi SDK, or a supervised
pi --mode rpcchild process
For built-in providers, 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 configuration names its
own agentDir. A custom OpenAI-compatible provider, including its key, is stored separately
in <agentDir>/models.json.
| The interface | With no usable model, pi-outpost shows a setup screen instead of a chat that could only fail. Pasting a key for a built-in provider writes <agentDir>/auth.json; declaring your own endpoint writes the provider and its key to <agentDir>/models.json. Either takes effect immediately — no restart |
pi-outpost login |
For headless servers, where no browser will ever open the interface: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 |
| Environment | export ANTHROPIC_API_KEY=… before starting. Nothing is written to disk |
A corporate gateway, vLLM, SGLang, Ollama, LM Studio — anything speaking the OpenAI API.
Declare it from the 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.
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:
export NODE_EXTRA_CA_CERTS=/path/to/corp-ca.pempi-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.
One server can hold several projects at once. Each has its own agent session, its own sandbox, its own file tree and its own session history; the agent keeps working in the projects you are not looking at.
- Open one from the project control in the header: browse the server's filesystem and pick a directory. It is usable straight away, and it is still there after a restart
- Switch without a reload. A turn running in the project you leave runs to completion, and its result is waiting when you come back. Unsent composer text is kept per project
- See what is happening elsewhere: every project reports whether it is stopped, starting, idle, working, waiting for you, or ready for review. Waiting means the agent needs an answer; ready for review means its authoritative Work Plan has no unfinished task and at least one task awaiting review. Both raise the selector's attention badge. When the window is in the background, a browser notification names the project and the kind of attention without exposing its plan or workspace content. Nothing ever interrupts the project you are looking at, and merely selecting it does not acknowledge either state
- Close one to release its resources; the sessions on disk stay, so reopening the same directory finds them again. Closing is refused while its agent is running a turn, and the last remaining project cannot be closed
- Projects that nobody is using are retired after
workspaceIdleTimeoutMs(30 minutes by default,0disables it) and rebuilt transparently on next use. A project running a turn, waiting for you, or ready for review is never retired - Start a side session with the + on a project's row: a second agent on the same project, in a fresh conversation on the model the project is using, running at the same time as the first — for a quick question or fix while a long task works. It is listed under its project, named after its conversation, and closed like a project; its conversation stays in the project's history. Both agents work in the same directory with the same sandbox, and nothing keeps their edits apart. A conversation is open in one session at a time, Settings applied to a project wait for its other sessions' turns and then reach all of them, and side sessions are not reopened after a restart
A configuration where no project has ever been opened behaves exactly as a single-project
server: cwd alone. "workspaceLock": true pins the server to one project and removes the
open/switch/close controls — and side sessions — entirely.
The browser tab is titled with the project shown, and the side session when it is one
(pi-outpost · fix typo — pi), so several tabs on one server can be told apart.
The gear menu changes what the agent may do, without editing a file or restarting the server. Accepted changes are written to the configuration file that is loaded and applied to a fresh agent session immediately.
| What | Notes |
|---|---|
| Sandbox root and writable root | Browse the server's directories and pick one — no need to know the host's paths by heart |
| Write and bash permissions | The same switches as sandbox.allowWrite / sandbox.allowBash |
| Agent resources | Manage agent resources opens one repository-first view for skills and extensions |
| Local folders | Add local folder… stores skill roots under userSkillPaths and extension roots under userExtensionPaths |
| Git repositories | Add Git repository… asks for the repository address and an editable local clone folder, then lists its skills grouped by folder, all off: turn skills on one by one or with All on / All off, then Save; later changes take effect with Apply. Extension roots are selected as before |
| Removing a repository | Remove repository unregisters it as a whole and, for a clone pi-outpost manages, deletes it from disk after a confirmation. Files you pointed it at elsewhere are kept |
Two lists, deliberately: what the configuration file declares (skillPaths,
extensionPaths) belongs to the deployment, and the interface can neither rewrite nor
remove it. What is added from Settings lives under its own key, and is the only thing the
interface offers to remove.
A deployment can forbid any of it: sandboxLocks locks individual sandbox fields,
extensionLock forbids extension-path changes (skill paths stay editable — loading code is
not the same act as pointing the agent at more text to read). What is locked is refused by
the server, not merely hidden by the interface. The enrollment warning is a browser-side
acknowledgement, not an authorization boundary; deployments that must forbid extension
activation should set extensionLock. Update confirmation is revision-bound and enforced
again by the server.
If the file cannot be written, nothing is applied and the session in front of you is left exactly as it was.
The suggested clone location is <user config dir>/resource-repositories/<name>-<hash>;
you may replace it with any local folder whose parent already exists. A preview recognizes
skills at the repository root, skills/, or .agents/skills/, and extensions under
extensions/, .pi/extensions/, or .agents/extensions/. Previewing reads metadata only:
it does not import extension code. Removing an activated resource path only updates the
configuration and reloads the agent — it never deletes the cloned repository.
Repositories are checked and updated conservatively. Only a clean branch strictly behind
its configured upstream can be fast-forwarded. Dirty, detached, ahead, diverged, and
upstream-less repositories explain why they are blocked; resolve local Git state in an
external terminal and check again. The updater never commits, stashes, discards, rebases,
pushes, switches branches, initializes submodules, or runs repository hooks. Updating a
repository containing extensions requires confirmation of the exact revisions, and
extensionLock blocks the whole mixed repository from updating. If Git advances but one
workspace cannot reload, the repository remains advanced and the dialog reports the
per-workspace reload failure. A repository of skills alone is reloaded for real; one that
supplies an extension is not — a running server cannot load an extension's new code, so
the update says its code runs after a restart, and the repository joins what
Restart pi-outpost in Settings runs.
Clone and fetch are non-interactive: existing credential helpers, SSH agents, and other non-prompting Git authentication continue to work, but the server never opens a terminal or askpass prompt. Credential-bearing addresses are redacted from browser errors and stored origins. An RPC runtime may omit skill paths and does not currently inventory extensions; those resources remain visible under Provenance unavailable and cannot be Git-managed until the runtime reports filesystem provenance.
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:
--config <path>--profile <name>→<user config dir>/profiles/<name>.json$PI_OUTPOST_CONFIG$PI_OUTPOST_PROFILE→<user config dir>/profiles/<name>.json./pi-outpost.config.json(the directory you launch from)<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.
Profiles. --profile work (or $PI_OUTPOST_PROFILE) reads
<user config dir>/profiles/work.json. A profile is an ordinary configuration 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 > file > default — except the fields you can change from Settings, where what you
applied wins and persists. 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.
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.
Relative paths are resolved against the configuration file's directory. A larger example lives
in pi-outpost.config.example.json.
| Key | Effect |
|---|---|
cwd |
Agent working directory, and the default project |
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. Applies to the cwd project; every other open project is confined to its own directory |
sandbox.allowWrite |
Adds edit/write, confined to sandbox.writableRoot (default false) |
sandbox.writableRoot |
Read-write zone: a subdirectory of root that edit/write are further confined to. Defaults to root itself. Ignored while allowWrite is false, and applies to the cwd project only: every other open project is writable in its whole directory |
sandbox.allowBash |
Adds bash — not path-confined, explicit opt-in (default false) |
sandboxLocks |
Which sandbox fields Settings may not change: root, writableRoot, allowWrite, allowBash |
workspaceLock |
Pin the server to one project: opening, closing and switching are refused, and no control is offered |
workspaceIdleTimeoutMs |
How long an unused project stays alive before it is retired (default 1800000 — 30 min; 0 never retires). A project running a turn, waiting for you, or ready for review is never retired |
openProjects |
The set of open projects. Written by the server when you open or close one — not hand-authored |
files.watch |
Watch the directories the file browser has listed, so the tree follows the workspace whoever changed it (default true). Set false where a watch is a liability — a network mount that emits no events, a spent inotify budget. The tree's ↻ control re-lists by hand either way |
| Key | Effect |
|---|---|
agentRuntime |
{ "mode": "embedded" } (default) keeps the pi SDK session in this process; { "mode": "rpc", "executable": "pi", "args": [] } supervises a pi --mode rpc child. See Agent runtimes |
agentRuntime.startupTimeoutMs / commandTimeoutMs / shutdownGraceMs |
RPC child startup, per-command and graceful-shutdown ceilings. Defaults: 60000, 300000 and 5000 ms |
tools |
Tool allowlist when no sandbox is configured, e.g. ["read","grep","find","ls"] |
noExtensions / extensionPaths / extensionScripts |
Disable extension discovery, or name extension files and directories the deployment loads |
userExtensionPaths |
Extension directories added from Settings. Written by the server; extensionPaths stays yours |
extensionLock |
Forbid adding or removing extension paths from Settings |
noSkills / skillPaths |
Disable skill discovery, or name skill files and directories. skillPaths loads even under noSkills. Disabling matters for real isolation: skills otherwise also load from ~/.agents/skills and from .agents/skills walked up from cwd, neither of which agentDir scopes |
userSkillPaths |
Skill directories added from Settings, loaded after skillPaths |
userSkillCollections |
Git repositories added as skill collections: [{ "path", "managed", "enabledSkills": ["folder/skill", …] }]. Only the listed skill directories load, after userSkillPaths. Written by the server |
noPromptTemplates / promptPaths |
Same for prompt templates (agentDir and the project's cwd/.pi/prompts) |
allowedModels |
Restrict the model switcher to these { "provider", "id" } pairs. Without it, every built-in model whose provider has auth is listed — often more variants than a deployment actually serves |
thinkingLevels |
Declare what thinking levels a model accepts, for one the runtime cannot describe. See Thinking levels |
systemPrompt / systemPromptFile |
Replace pi's built-in system prompt entirely (mutually exclusive). Project context files, skills and appendSystemPrompt still layer on top |
appendSystemPrompt |
Extra paragraphs appended after the system prompt |
webContext |
Tell the agent its replies render in this interface — markdown, inline images, file links (default true) |
offline |
Never fetch remote model catalogs. On a host that cannot reach them, that request hangs and stalls every credential change by 20 s. Built-in and models.json providers are unaffected. --offline and PI_OFFLINE also turn it on |
| Key | Effect |
|---|---|
pdf.maxBytes |
Largest PDF the viewer may load and pdf_extract may read (default 26214400 — 25 MB). Every other file keeps the 1 MB limit |
docx.maxBytes / xlsx.maxBytes / pptx.maxBytes |
The same ceiling, per format, for the Office extractors |
structuredExchange.maxBytes |
Largest structured-exchange document the viewer may open (default 8000000, the widest ceiling any supported schema version declares). Each version's own ceiling is applied after the document says which one it claims, so a version 1 document is still bounded at its published 4 MB. Recognition is by the document's declared schema, never by its extension, so other JSON keeps the 1 MB preview limit. A larger value is clamped to the contract's |
| Key | Effect |
|---|---|
server.port |
Port to listen on (default 3141). --port and PI_OUTPOST_PORT/PORT override it |
server.host |
Address to bind (default 127.0.0.1 — only change this if you have read the security note above) |
server.allowedOrigins |
Extra exact Origins accepted on the WebSocket, and given CORS headers on the HTTP endpoints |
server.token |
Shared secret required on the WebSocket, /branding and /files/raw (PI_OUTPOST_TOKEN overrides). Mandatory in practice off loopback |
openBrowser |
Whether starting the server opens the interface (default: wherever a desktop session exists) |
openIn |
"window" (its own window, the default) or "browser" (a tab). openBrowser still decides whether |
branding |
title (default "π"), welcome message, accentColor |
branding.defaultTheme |
"light" | "dark" | "system" (default), used when the client has no stored preference |
branding.allowThemeToggle |
Show the theme toggle (default true). Set false when a host app drives the theme |
terminal.enabled |
Enable integrated interactive web terminal (default false — explicit opt-in only). See Integrated Terminal |
terminal.shell |
Path to the shell executable (default: Git Bash -> PowerShell on Windows; $SHELL -> /bin/zsh -> /bin/bash on Unix) |
terminal.shellArgs |
Arguments passed to the shell (default: ["-l"] on Unix login shells) |
embed.workspaceControls |
What a mounted widget offers: "settings" (default, one project), "root" (a compact root chooser), "projects" (open/switch/close) |
updateCheck / updateRegistry |
See Staying up to date |
gitPath |
Path to the git executable. Unset, git is found on PATH and then where installers put it. See Git |
Git features need a git to run. It is looked for in this order:
gitPath, when the configuration names onegit, asPATHresolves it- The standard install locations —
C:\Program Files\Git\cmd\git.exeand friends on Windows,/usr/bin/gitand the Homebrew and Xcode paths on macOS,/usr/bin/gitand/usr/local/bin/giton Linux
The third step exists because git is routinely installed and absent from the PATH a
server process inherits — a Windows machine where VS Code shows git perfectly while a
service launched from a shortcut cannot find it at all.
A gitPath that is not a runnable git fails startup, naming it. It never falls back to
another git: naming an executable is an instruction, and quietly running a different one
would answer questions about the wrong installation.
When git features are missing, Settings says why: the executable could not be run, this project is not in a repository, or git refused it — with git's own message, which for the common "detected dubious ownership" names both the directory and the remedy.
The thinking-level control offers only the levels the current model accepts — which the
runtime normally reports. For a model declared against your own endpoint it cannot: the
SDK does not recognise the name, the control falls back to offering everything, and a
model that cannot think at all gets a slider that goes to xhigh and snaps back.
Declare the answer instead:
"thinkingLevels": [
{ "provider": "maison", "levels": ["off"] },
{ "provider": "maison", "id": "big", "levels": ["off", "low", "medium"] }
]An entry without id covers every model of that provider; where both could apply, the one
naming the model wins. Levels are normalised as a runtime-reported list is — unknown names
dropped, canonical order, off always available — and an entry naming no usable level
fails startup rather than leaving a model nothing can be asked of.
A declaration is authoritative: it replaces what the runtime reports, because the
setting exists precisely for the models the runtime is guessing about. A set_thinking
naming a level outside a declared set is refused rather than forwarded, whichever client
sends it.
Changing the model settles the level on the new model's scale: a session on high
moving to a model that stops at low lands on low, and one moving to a model that
accepts no thinking lands on off — never a step up, which would spend more effort than
was asked for. The interface is told what it settled at, so it cannot go on showing a
level the agent is not using. A model with a single accepted level says so in the control
rather than offering a slider with nowhere to go.
Light and dark themes ship with the interface. Precedence: a local pick from the toggle
(persisted in localStorage) or an explicit override (the embed widget's theme option or
setTheme(), or a host page's postMessage) beats branding.defaultTheme, which falls
back to the OS preference.
agentRuntime.mode decides what actually runs the agent. embedded (the default) keeps a
pi SDK session inside this process. rpc supervises a pi --mode rpc child — to match an
existing pi installation, or to isolate a crash.
The embedded SDK runtime is the supported target; rpc is best effort. Features are
designed, measured and proven against the SDK session. The RPC dialect gets what it can
express: where a capability has no equivalent there, it is reported as unavailable rather
than faked, and a feature may land for embedded first — or only. That is a deliberate
priority, not an oversight, and the list below is what it costs today.
pi-outpost appends --mode rpc and derives --session-dir itself, so args may contain
neither, nor --tools/--system-prompt (those come from tools/systemPrompt), nor any
flag that would make the child print something and exit. The rest of the configuration
travels to the child, and pi-outpost's own tools go with it, so the same file describes the
same agent either way.
Two things to know before switching: 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; isolate it with a container or a dedicated user instead. And a few
features have no RPC equivalent and say so rather than failing quietly: storing credentials,
declaring a provider, changing the sandbox from Settings, tree navigation, and editing a
past message. Sessions are not auto-titled there either.
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 doctor [options] check whether this installation can start and serve, and say what stops it
pi-outpost login --provider <name>
store an API key in <agentDir>/auth.json
pi-outpost build-exe [options]
build a standalone executable from this installation
pi-outpost update [--check] move to the newest published version, or just look
| 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) |
--port <n> / --host <addr> |
Where to listen (default 127.0.0.1:3141) |
--offline |
Never fetch remote model catalogs |
--open / --no-open |
Open the interface once listening (default: wherever a desktop session exists) |
--open-in <shape> |
window (its own window, the default) or browser (a tab) |
--terminal / --no-terminal |
Enable or disable the integrated web terminal (default false) |
-h, --help / -v, --version |
|
login --provider <name> |
Store a key for that provider (prompted, or read from stdin — never a flag) |
init --global |
Write to the user config directory instead of ./ |
init --force |
Overwrite an existing file |
build-exe --out <path> |
Where to write the executable (default ./pi-outpost, .exe on Windows) |
build-exe --force |
Replace an existing file at that path |
update --check |
Report what is available and install nothing |
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.
When the page will not load and it is not obvious why, run doctor in the directory
you start the server from. It reports, in one pass and without stopping at the first
problem:
- installation — the version, and whether this is a global install, a checkout, an
npxrun or a standalone executable - configuration — which file a start would read, or, when there is none, both paths
it looked in and the
initthat writes one - settings — the address to open, the agent's working directory, the runtime, whether a token is set (never its value) and whether the terminal is on
- address — whether the port is free, already serving another Pi Outpost, or taken by something else
- web UI — whether this installation actually has an interface to serve
- git, and node-pty when the terminal is enabled
It exits non-zero when something would stop the server from serving, so it can gate a
script. Unlike config, it never needs a configuration file to run — that absence is
one of the things it is there to report.
The most common cause of "the page does not connect", in a directory you have not used before: there is no configuration file, so the server prints the paths it searched and exits before it ever binds the port. Installing Pi Outpost globally does not create one — the install and the configuration are separate acts:
pi-outpost init # a configuration for this directory
pi-outpost init --global # one that every directory falls back to
The global file lives in <user config dir>/config.json — $XDG_CONFIG_HOME/pi-outpost
or ~/.config/pi-outpost, which on Windows means C:\Users\<you>\.config\pi-outpost
and not %APPDATA%. doctor prints the resolved path, so there is nothing to guess.
When enabled via --terminal, PI_OUTPOST_TERMINAL=1, or "terminal": { "enabled": true } in the configuration file, Pi Outpost provides an interactive pseudo-terminal (PTY) directly in the browser:
- Full PTY multiplexing: Real interactive login shells (
bash,zsh,powershell) with 24-bit ANSI color and xterm.js emulation over the existing authenticated WebSocket connection. - Multi-Tab with inline renaming: Create tabs (
+), close them (✕), and double-click on any tab title to rename it. - Background minimization: Press
Ctrl+\`` (orCmd+`) or click>_ terminal` in the header to minimize the panel without interrupting active builds, commands, or logs. - 1-Click Workspace Repointing: The terminal detects current working directory (
pwd) in real time (supporting OSC 7). Clicking📁 <dir> → open as projectrepositions the AI agent, file browser, and git view to that subdirectory.
When the server cannot start — a port already taken, an unreadable directory, a bad configuration — it says which of those it was in a sentence, not a stack trace. A window launched from a file manager, which would otherwise close with the process and take the message with it, waits for a keypress first.
The server checks once a day whether a newer version has been published, and says so in one
line if there is — in the terminal, and in the standalone interface as a notice with the
command to copy, which can be dismissed for that version. It never installs pi-outpost on
its own — pi-outpost update is the only thing that installs it, and only when you run it
without --check.
pi packages — extensions installed with pi install npm:…, such as
@gotgenes/pi-permission-system or openlore — are listed in Settings with the version
installed and, once checked, whether a newer one is published; a check that fails says why.
Update installs the newer version after a confirmation naming both versions. A running
server cannot load an extension's new code, so the package then shows restart to use it,
and Restart pi-outpost stops and starts the server again in the same terminal: every
open window reconnects by itself and conversations are kept. Restarting is refused while an
agent is running a turn, and is never offered in an embedded widget; extensionLock forbids
updating. A version changed from a terminal (pi update) is marked the same way.
What update does depends on how this copy was installed, which it works out rather than
asking:
| How you run it | What update does |
|---|---|
| Global npm install | Prints npm install -g pi-outpost@latest, runs it, reports the version it moved to |
| Source checkout | Refuses, and tells you to git pull — installing would put a second copy elsewhere and leave the one you are running untouched |
npx pi-outpost |
Explains that your next npx already fetches the newest version |
| Standalone executable | Refuses to overwrite itself, and points at the releases |
| Anything it cannot recognise | Refuses rather than guessing, and prints what it compared: the entry it runs from, npm's global node_modules (or that npm root -g gave no answer), and the runtime |
A check that fails is never reported as "up to date": update --check says it could not
check, and why, and exits non-zero.
| Key | Effect |
|---|---|
updateCheck |
false disables checking entirely. true enables it even under offline. Unset, offline decides |
updateRegistry |
Optional registry override, when npm's own configuration does not name the right one |
Version checks normally run through npm view, so an existing .npmrc remains the source
of truth for an internal registry, authentication, CA certificates and proxies. No duplicate
pi-outpost setting is required for a Nexus setup that already works with npm.
offline means "remote model catalogs are unreachable", which is not the same network as a
package registry — a host air-gapped from the former can still reach an internal npm proxy,
and that is exactly the deployment where knowing a release exists matters. So offline is a
default for update checking, not a veto.
npm run startBuilds the interface once and starts one Node process serving the app, /ws,
/branding and /health together on server.port — nothing else to run or keep track of.
Point a process manager (systemd, pm2, Docker CMD, …) at that one command.
It reads your configuration (./pi-outpost.config.json, or any of the locations above);
with none, it refuses to start and says so. There is no hot reload here: rebuild
(npm run build --workspace web) and restart after a UI change.
To distribute a version that needs no Node.js at all — a Windows .exe for non-technical
users, say — see docs/sea-packaging.md.
The standalone interface declares itself to the browser, so it can be installed from the address bar and opened from the desktop or taskbar: its own window, its own name and icon, no tabs.
It is the same interface, on the same server, with the same sessions — installation changes where it runs, never what it is. It also caches nothing of itself: with the server down, an installed app says it cannot reach it rather than showing a stale copy of a previous build.
A mounted widget claims nothing on its host page: no manifest, no icon, no change to whether the host page itself is installable.
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, everything else 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"
token: "…", // optional; a host that already authenticates its user sees no token screen
workspace: "/srv/projects/acme", // optional; which project this widget shows
});
widget.setTheme("light"); // change the theme at runtime
widget.unmount(); // tear down the React treeWhich project a widget shows is the host's decision: workspace names it by its
resolved root, and a root that is not open falls back to the default rather than failing.
What the widget offers around it is the server's decision, via
embed.workspaceControls: settings (the default — one project, sandbox root editable
through Settings only), root (a compact root chooser in the header) or projects (the
open/switch/close controls the standalone app has). A workspaceLock on the server
suppresses project controls whatever the policy says.
Two things to configure server-side, whatever the topology:
server.allowedOrigins: the widget's WebSocket carries the host page's origin, not pi-outpost's — add it explicitly. Even same-domain deployments need this; onlylocalhost/127.0.0.1are trusted automatically- CORS: an origin listed there also receives CORS headers on the HTTP endpoints
(
/branding,/health,/files/raw, the static app), so a genuinely cross-origin widget works without a proxy in front. The allowlist is the whole of it
A raw iframe (<iframe src="https://your-pi-outpost-server">) still works too, and honours
branding.allowThemeToggle: false plus the host-driven theme channel:
iframeWindow.postMessage({ type: "pi-outpost:set-theme", theme: "light" }, "https://your-pi-outpost-origin")Extensions using pi's Custom
UI
(ctx.ui.select/confirm/input/editor/notify/setStatus/setWidget/setTitle/setEditorText) work
in the web interface: dialogs render as a modal, notify() as a toast, setStatus() as a
header badge, setWidget() above or 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,
while TUI-only features (custom(), custom footers/headers/editors, terminal input, themes)
are no-ops, same as in RPC mode.
Custom messages (pi.sendMessage() with a customType) show up too, without the extension's
terminal MessageRenderer: they fall back to pi's own default look, with any details
payload collapsed behind a toggle. Messages sent with display: false stay hidden, as in the
TUI.
A long-running tool can report how far along it is. From execute, call onUpdate with a
progress fraction between 0 and 1 in details — the same call you already make to
stream partial output:
async execute(toolCallId, params, signal, onUpdate) {
for (let i = 1; i <= steps; i++) {
await doOneStep();
onUpdate?.({ content: [{ type: "text", text: `step ${i}/${steps}` }], isPartial: true, details: { progress: i / steps } });
}
return { content: [{ type: "text", text: "done" }] };
}The interface then shows a determinate bar on the tool card while the call runs. It is a
hint: a value outside 0..1 is clamped, the bar appears once the first fraction arrives and
disappears when the tool finishes, and a tool that reports nothing looks exactly as it did.
The Work Plan belongs to the agent. For non-trivial work it is its explicit working state: a persistent hierarchy used to decompose objectives, track execution and dependencies, record resources and blockers, and reconcile verification before declaring the work complete. It is not a second activity log, and trivial exchanges need no plan.
Tasks use five states: todo, in_progress, blocked, needs_review and done. A blocked
or review state can carry a reason, and tasks can link to resources; workspace resources open
directly in the file viewer. The panel is read-only for now, so the conversation stays your
control surface while the agent owns the plan through its work_plan tool.
The agent sees that tool in two halves. work_plan — creating a plan, adding, updating,
moving and removing tasks — is always there. work_plan_extended, which sets a task's
dependencies, resources and verification evidence or replaces the plan wholesale, appears
only once the session has a plan: every one of those operations acts on a task that must
already exist, and their schemas are the expensive half to send on turns that never use
them. It goes again after five turns in which nothing touched the plan, and any later
work_plan call brings it straight back — so a plan opened in the morning stops charging
for the afternoon that moved on to something else. A supervised pi --mode rpc child gets
both at all times, having no way to change its published toolset.
When a non-empty plan contains at least one needs_review task and every other task is either
needs_review or done, the project becomes ready for review in the project selector.
This is derived from the persisted plan, not from a turn or tool merely ending. Opening or
switching to the project does not clear it: tell the agent what you accept or what must change.
Accepted review tasks move to done; meaningful resumed work moves the relevant task back to
an active state, so the project stops being ready until it reaches the review boundary again.
Tasks can also carry agent-owned verification or supporting evidence. Each generic record has
an id, a free-form type, a result (passed, failed, inconclusive or
informational), and at least a concise summary or a resource reference. The agent replaces
one task's complete evidence collection with set_evidence, preserving older failures when it
wants to append another result:
{"action":"set_evidence","taskId":"build","evidence":[{"id":"tests","type":"test","result":"passed","summary":"Focused tests passed"},{"id":"probe","type":"external-check","result":"failed","summary":"External probe failed"}]}Evidence and status are deliberately independent: evidence never completes or blocks a task,
and marking a task done never fabricates evidence. Pi Outpost does not automatically turn tool
activity or conversation claims into evidence; agents record evidence explicitly through the
structured tool.
Each plan is stored beside its session file. It is restored on reconnect and session resume, replaced when the active session changes, copied when a conversation is forked, and independent thereafter. Compaction summarizes conversational context only: it never alters the plan or its evidence. Existing version-1 plans and task inputs without evidence remain valid and normalize to empty evidence collections. Sessions created before Work Plans open without a panel.
The Outcome control in the header opens a concise review of the current workspace. It is available even for older sessions without a Work Plan. The view is assembled directly from structured state already owned by Pi Outpost: current Work Plan tasks, their recorded evidence, and current git working-tree status across every repository in the workspace. Opening or refreshing it does not ask a model to write a summary and does not approve the work.
Plan progress and verification are deliberately separate. Tasks keep their exact status and reason. Verification is conservative: any failed evidence yields failed; otherwise any inconclusive evidence yields inconclusive; otherwise passing evidence yields passed. Informational evidence remains visible but does not prove verification, and no evidence is shown as not recorded. A completed task is therefore not automatically a verified task.
Changed files retain their repository, workspace-relative path, and git state. A clean repository, no repository, a partially unavailable repository set, and globally unavailable git status are distinct states—none is presented as successful completion. Select a task to open it in Work Plan, or select a changed file to open the existing confined file/diff viewer. Safe HTTP(S) evidence links open externally; unsupported references remain readable text without a dead control.
Outcome data never crosses workspace boundaries. Refreshes are correlated with the current workspace and session, so an older response is discarded after a project or session switch, and a result already on screen is dropped rather than carried into the next one. Switching project closes the drawer with the project it described; reopening it asks the workspace now bound. An Outcome left open across a dropped connection is asked for again once the connection is back. Every request is answered: a composition that fails says why in the drawer, and one the server never answers is given up on, so Refresh always works rather than being disarmed by a request that stayed outstanding. The section contract is extensible: future structured sources can add sections without changing the existing plan, verification, or changed-file sections.
Working on pi-outpost — layout, dev server, test suites and why the Linux leg exists — is
covered in docs/development.md.
shared/ (protocol types — events, ChatItem, DialogRequest)
(structured-exchange: schema, validation, and the figure — one list of
shapes both renderers draw, so neither can decide anything alone)
ui/ (React components & hooks) server/ (Fastify + ws)
┌──────────────────────────┐ ┌──────────────────────────┐
│ @pi-outpost/ui exports │ │ workspace registry │
│ CopyButton, DiffBlocks, │ /ws │ └ per project: session, │
│ ToolCard, useAgent, … │ ◄──────► │ sandbox, files, git │
│ │ JSON │ agent runtime boundary │
└────────┬─────────────────┘ │ ├ embedded AgentSession │
│ import │ └ pi --mode rpc child │
▼ └──────────────────────────┘
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/, per project — reconnecting clients receive the
full history of the project they are bound to.
Single-tenant by design. Everyone connected to a server shares the same projects and the same conversations: there is one identity, not one per person. That is deliberate for a personal deployment, and the thing to fix before a shared one — see #4, multi-user support.

