pi-ws
v1.1.1
Published
Embeddable WebSocket bridge for running local Pi coding-agent RPC sessions from Node.js apps.
Maintainers
Readme
pi-ws
Embeddable Node.js WebSocket bridge for running local
pi coding
agent RPC sessions from browsers, internal tools, dashboards, and custom
automation.
pi-ws is library-first: you embed PiWs in your own Node.js process, keep
control over auth and routes, and get a built-in /ws/pi bridge that starts one
local Pi subprocess per WebSocket connection.
browser/client ── WebSocket JSON ──▶ pi-ws ── JSONL stdin/stdout ──▶ pi --mode rpcFeatures
- Built-in Pi RPC WebSocket route at
/ws/pi. - Library extension points for custom HTTP routes, WebSocket routes, and direct
uWebSockets.jsinstallers. - Optional hook-based auth; no authentication is enabled unless you add hooks or opt into the reference token hook.
- Reference shared-token auth for WebSocket and HTTP helpers.
- Artifact transfer for generated files, enabled by default.
- Binary WebSocket streaming for images, PDFs, CSVs, text, archives, audio, video, and other agent-created files.
- Per-session process sandbox workspace with reduced environment by default.
- Optional external system sandbox wrapper such as
bwraporfirejail. c12-based config loading for binaries and deployments.- Structured
pinologs and example launchers withpino-pretty. - TypeScript-first public API.
Installation
pnpm add pi-wsnpm install pi-wsNode.js >=22.19.0 is required.
Documentation
- Getting started - prerequisites, first server, and first WebSocket message.
- Examples - runnable browser chat, provider keys, auth, artifacts, and sandbox directories.
- Security guide - threat model, auth, sandbox limits, and deployment checklist.
- Contributing guide - local setup, test commands, generated docs, and pull request expectations.
- API reference - generated TypeScript API docs.
Quick Start
import { PiWs } from 'pi-ws';
const pipe = new PiWs({
host: '127.0.0.1',
port: 8787,
chatExample: false,
});
pipe.handle({
method: 'get',
path: '/health/application',
handler: (res) => {
res
.writeHeader('content-type', 'application/json')
.end(JSON.stringify({ ok: true }));
},
});
await pipe.listen();The built-in Pi bridge is available at:
ws://127.0.0.1:8787/ws/piClients send Pi RPC JSON objects as WebSocket text frames. pi-ws validates
each frame as a JSON object, forwards it to Pi as JSONL, parses Pi stdout back
to JSON objects, and sends responses back as WebSocket text frames.
Auth Is Opt-In
pi-ws does not implement mandatory server auth and does not enable auth by
default. Applications decide how to protect the route using hooks, reverse
proxies, network policy, or their existing auth stack.
The package includes a small token-based reference implementation for projects that want a simple built-in option:
import { createStaticTokenAuthHook, PiWs } from 'pi-ws';
const pipe = new PiWs();
pipe.addHook(
'onAuth',
createStaticTokenAuthHook({
token: process.env.PI_WS_AUTH_TOKEN ?? 'change-me',
queryParam: 'token',
createSession: async (request) => ({
clientAddress: request.remoteAddress ?? 'unknown',
}),
}),
);
await pipe.listen();Browser clients that cannot send custom WebSocket upgrade headers can authenticate with the reserved first message:
{ "token": "change-me", "type": "pi_ws_auth" }For HTTP routes, use the same token policy with protectHttpHandler():
import { protectHttpHandler, StaticTokenAuthorizer } from 'pi-ws';
const authorize = new StaticTokenAuthorizer({
token: 'change-me',
queryParam: 'token',
}).authorize;
pipe.handle({
method: 'get',
path: '/api/private',
handler: protectHttpHandler({
authorize,
handler: (res) => {
res.end('ok');
},
}),
});Artifacts
Artifacts are enabled by default. Each WebSocket session gets a private
artifact directory under the configured artifact root, and the Pi subprocess
receives the full path through PI_WS_ARTIFACT_DIR.
The browser does not need the absolute server path. The ready event exposes safe status only:
{
"artifactDirName": "s-8588573f292b",
"artifactsEnabled": true,
"sandboxMode": "process",
"type": "pi_ws_ready"
}artifactDirName is only the last directory name, not the full path.
Small files are sent as one metadata frame plus one binary frame:
pi_ws_artifact- binary file bytes
Large files are sent as chunk metadata plus binary frames:
pi_ws_artifact_start- repeated
pi_ws_artifact_chunk - one binary frame per chunk
pi_ws_artifact_end
Example config:
const pipe = new PiWs({
artifacts: {
enabled: true,
dir: './.pi-ws/artifacts',
maxFileBytes: 25 * 1024 * 1024,
chunkSizeBytes: 256 * 1024,
logLevel: 'info',
logFile: './.pi-ws/pi-ws.log',
},
});Generated files are discovered after they are stable on disk. Symlinks and paths outside the artifact root are ignored.
Sandbox And Environment
The default sandbox mode is process. It creates one session root under
sandbox.cwd and places cwd, HOME, and TMPDIR inside that root. This is
process-level isolation and prompt guidance, not an OS security boundary.
const pipe = new PiWs({
sandbox: {
mode: 'process',
cwd: './.pi-ws/sandbox',
envPolicy: 'minimal',
allowReadDirs: ['./inputs'],
allowWriteDirs: ['./scratch'],
denyServerDirectory: true,
},
});Use system mode when you need OS-enforced isolation:
const pipe = new PiWs({
sandbox: {
mode: 'system',
command: 'bwrap',
args: [
'--ro-bind',
'{allowReadDirs}',
'--bind',
'{allowWriteDirs}',
'--chdir',
'{sandboxCwd}',
],
},
});envPolicy: "minimal" forwards only a small provider-focused allowlist plus
sandbox.envAllowlist. Explicit sandbox.env values remain the supported way
to pass application-specific tool configuration, but protected sandbox
variables such as HOME, TMPDIR, and PI_WS_SANDBOX_CWD stay controlled by
the bridge.
Library API
PiWs keeps the extension surface intentionally small:
handle()adds HTTP routes.route()adds WebSocket routes.use()installs low-leveluWebSockets.jshandlers.addHook('onRequest', hook)runs pre-upgrade checks for/ws/pi.addHook('onAuth', hook)authenticates/ws/pifrom upgrade metadata or the reserved first WebSocket message.configurePi(),configureArtifacts(),configureSandbox(), andconfigureTls()merge focused runtime config.
Generated API docs:
Binary Usage
The pi-ws binary is a thin wrapper around the library:
import { loadConfig, PiWs } from 'pi-ws';
const config = await loadConfig();
const pipe = new PiWs(config);
await pipe.listen();Run after installation:
pi-wsConfiguration is resolved in this order:
- explicit
loadConfig({ overrides })values PI_WS_*environment variablespi-ws.config.*files in the current working directory- the
pi-wsfield inpackage.json - built-in defaults
Example pi-ws.config.ts:
import { definePiWsConfig } from 'pi-ws';
export default definePiWsConfig({
host: '127.0.0.1',
port: 8787,
pi: {
provider: 'openai',
model: 'gpt-4.1',
},
});Environment Variables
Core server:
PI_WS_HOST- bind host, default127.0.0.1PI_WS_PORT- bind port, default8787PI_WS_PREFIX- WebSocket prefix, default/wsPI_WS_MAX_PAYLOAD_BYTES- max inbound frame size
Reference token auth:
PI_WS_AUTH_TOKEN- enables the reference token auth hook for/ws/piPI_WS_AUTH_HEADER- token header, defaultauthorizationPI_WS_AUTH_SCHEME- token scheme, defaultBearerPI_WS_AUTH_QUERY_PARAM- optional query-string token parameterPI_WS_AUTH_REALM- optionalWWW-Authenticaterealm
Pi subprocess:
PI_WS_PI_COMMAND- optional Pi command overridePI_WS_PI_ARGS- whitespace args or JSON string arrayPI_WS_PI_CWD- optional Pi subprocess cwdPI_WS_PI_AGENT_DIR- optionalPI_CODING_AGENT_DIRPI_WS_PI_PROVIDER- Pi providerPI_WS_PI_MODEL- model id or patternPI_WS_PI_THINKING- thinking levelPI_WS_PI_NAME- session display namePI_WS_PI_SYSTEM_PROMPT- replace Pi system promptPI_WS_PI_APPEND_SYSTEM_PROMPT- string or JSON string arrayPI_WS_PI_EXTENSIONS- string or JSON string arrayPI_WS_PI_PROMPT_TEMPLATES- string or JSON string array
Artifacts:
PI_WS_ARTIFACTS_ENABLED- enable/disable artifact transferPI_WS_ARTIFACTS_DIR- artifact rootPI_WS_ARTIFACTS_MAX_FILE_BYTES- max transfer sizePI_WS_ARTIFACTS_CHUNK_SIZE_BYTES- binary chunk sizePI_WS_ARTIFACTS_SCAN_INTERVAL_MS- discovery poll intervalPI_WS_ARTIFACTS_STABILITY_WINDOW_MS- file stability windowPI_WS_ARTIFACTS_LOG_LEVEL- pino log levelPI_WS_ARTIFACTS_LOG_FILE- pino log destination
Sandbox:
PI_WS_SANDBOX_MODE-off,process, orsystemPI_WS_SANDBOX_CWD- sandbox rootPI_WS_SANDBOX_ALLOW_READ_DIRS- JSON string arrayPI_WS_SANDBOX_ALLOW_WRITE_DIRS- JSON string arrayPI_WS_SANDBOX_ENV_POLICY-inherit,minimal, orallowlistPI_WS_SANDBOX_ENV_ALLOWLIST- JSON string arrayPI_WS_SANDBOX_ENV- JSON object of explicit env valuesPI_WS_SANDBOX_COMMAND- external wrapper command forsystemPI_WS_SANDBOX_ARGS- wrapper args
TLS:
PI_WS_TLS_KEY_FILE/PI_WS_TLS_CERT_FILE- enable HTTPS/WSSPI_WS_TLS_CA_FILE- optional CA bundlePI_WS_TLS_PASSPHRASE- optional private-key passphrasePI_WS_TLS_DH_PARAMS_FILE- optional DH params filePI_WS_TLS_CIPHERS- optional OpenSSL cipher suite overridePI_WS_TLS_PREFER_LOW_MEMORY_USAGE- optional TLS memory tuning flag
Examples
From this repository:
mise install
pnpm install
pnpm build
node examples/embedded-server.mjsOpen:
http://127.0.0.1:8787/examples/chat/The guided chat launcher:
pnpm example:chatSee examples/README.md for provider keys, base URL configuration, auth, artifact previews, and sandbox directories.
Development
mise install
pnpm install
pnpm testUseful scripts:
pnpm dev- watch-mode binary entrypointpnpm demo- run the local demo serverpnpm build- build Node output and generated API docspnpm build:docs- regenerate API docspnpm lint- type, style, and dependency audit checkspnpm test- lint and unit tests
Architecture
Route registration order:
- built-in
/healthz - optional
/examples/chat/ - built-in
${wsPrefix}/pi - user HTTP routes added with
handle() - user WebSocket routes added with
route() - low-level installers added with
use() - final catch-all 404
flowchart TD
Client[Browser or WS client]
UWS[uWebSockets.js]
Bridge[pi-ws bridge]
Pi[Pi CLI RPC subprocess]
Client -->|WS text JSON| UWS
UWS -->|message event| Bridge
Bridge -->|stdin JSONL| Pi
Pi -->|stdout JSONL| Bridge
Pi -->|stderr and lifecycle| Bridge
Bridge -->|WS text and binary frames| UWS
UWS -->|frames| Client