@av-pi-studio/server
@av-pi-studio/server
The Pi-Studio daemon — the long-lived server process that runs on a developer's machine and is the heart of Pi-Studio. It supervises AI-agent processes, PTY terminals, git worktrees, projects, chat rooms, schedules, and loops, and exposes a single WebSocket JSON+binary API (plus a small HTTP surface) that every client — the CLI, the web UI, and future native apps — connects to.
Your code never leaves your machine: the daemon runs locally, talks to the pi agent locally, and
persists all state under a local home directory.
Table of contents
- What the daemon does
- Requirements
- Install & build
- Running the daemon
- Configuration
- The wire API
- Agent providers
- Persistence
- Architecture
- Security model
- Logging
- Development
- Key invariants
What the daemon does
A single daemon process owns all runtime state and mediates every operation:
- Agents — creates, runs, interrupts, updates, resumes, and archives AI coding-agent sessions through a provider-neutral interface. Streams every turn event (assistant messages, reasoning, tool calls, completion) to subscribed clients in real time.
- Terminals — spawns and multiplexes PTY processes over the same WebSocket using binary frames, with screen-buffer snapshots so late subscribers see the current screen.
- Projects & git — opens projects, tracks workspaces, runs git status/branch/diff/commit, manages worktrees, and integrates with the GitHub API for PRs/issues.
- Orchestration — chat rooms with
@mentions, cron/interval schedules that fire agent prompts, and iterative worker+verifier loops. - Files — directory listing, text/binary file preview, and token-based chunked file download.
- Service proxy — an HTTP reverse proxy that routes to localhost services started by agents.
The daemon is provider-agnostic: the rest of the code only ever touches the AgentClient /
AgentSession interfaces in src/agent/provider-contract.ts. Two providers ship today — the real
pi provider and an in-process mock.
Requirements
- Node.js ≥ 20 (developed and tested on Node 24). ESM only (
"type": "module"). - npm with workspaces (this package lives in the Pi-Studio monorepo).
- For the real
piprovider: pi credentials only. ThepiCLI is bundled as a dependency (@earendil-works/pi-coding-agent) — the daemon launchesnode <pkg>/dist/cli.js --mode rpc, so no globalpiinstall is required. Provide credentials via an API key (ANTHROPIC_API_KEY, etc.) in the daemon's environment, or a configured~/.pi/agent/auth.json. - The built-in
mockprovider needs no credentials and is ideal for smoke tests.
Install & build
From the monorepo root:
npm install # install all workspace deps
npm run build:server # build this package (compiles protocol + highlight first via project refs)
Or build everything: npm run build.
The build emits dist/. The production entry point is dist/daemon/main.js, also exposed as the
pi-studio-daemon bin.
Running the daemon
Simplest (from monorepo root)
npm start # builds the server, then runs the daemon in the foreground
npm run start:server # runs the already-built daemon without rebuilding
Directly:
node packages/server/dist/daemon/main.js
On startup the daemon prints its identity and readiness:
pi-studio daemon listening on http://0.0.0.0:6767
serverId: 3f2a…
home: /home/you/.pi-studio
provider: pi
ws: ready
Press Ctrl+C to stop
By default the daemon:
- listens on
0.0.0.0:6767(override withPI_STUDIO_LISTEN) — reachable over the LAN - stores all state under
$PI_STUDIO_HOME(default~/.pi-studio) - writes logs to
$PI_STUDIO_HOME/logs/ - uses the
piprovider
It runs in the foreground; Ctrl-C (SIGINT) or SIGTERM triggers a clean shutdown that closes the HTTP/WS servers and releases resources.
Verify it's up
curl http://127.0.0.1:6767/api/health
# → {"status":"ok"}
/api/health is exempt from Host-allowlist and auth checks, so it always answers.
Dev daemon
src/daemon/dev-main.ts is a development entry that wires the full feature surface and binds
0.0.0.0 with developer-friendly defaults. From the root:
npm run dev:daemon
The production
bootstrap.tsand devdev-bootstrap.tsboth register the full RPC surface;bootstrap.tsis production-grade (real provider, disk persistence, config loading) whiledev-bootstrap.tsis for local testing and must never be imported bybootstrap.ts.
Configuration
Configuration comes from two sources, merged with environment variables winning over the file:
$PI_STUDIO_HOME/config.json(optional — a missing or corrupt file is treated as{}).- Environment variables (overlaid last).
Environment variables
All optional.
| Variable | Default | Purpose |
|---|---|---|
PI_STUDIO_HOME |
~/.pi-studio |
State + config + logs directory |
PI_STUDIO_LISTEN |
0.0.0.0:6767 |
Daemon listen address (host:port) |
PI_STUDIO_PASSWORD |
(unset) | Require this password for connections (bcrypt-checked) |
PI_STUDIO_HOSTNAMES |
localhost,*.localhost |
Allowed Host header values (comma-separated, or true to allow all) |
PI_STUDIO_SERVER_ID |
(persisted/generated) | Stable server identity |
PI_STUDIO_RELAY_ENDPOINT |
(unset) | Relay server to dial outbound to when daemon.relay.enabled (host:port) |
PI_STUDIO_RELAY_USE_TLS |
false |
Use wss:// for the outbound relay dial (1/true/yes/on) |
PI_STUDIO_RELAY_PUBLIC_ENDPOINT |
(unset) | Client-facing relay address, if different from the daemon's own dial target |
PI_STUDIO_RELAY_PUBLIC_USE_TLS |
false |
TLS setting for the client-facing relay address (independent of the outbound dial) |
PI_STUDIO_SERVICE_PROXY_LISTEN |
(unset) | Service-proxy listen address |
PI_STUDIO_SERVICE_PROXY_PUBLIC_BASE_URL |
(unset) | Public base URL advertised for proxied services |
PI_STUDIO_SERVICE_PROXY_ENABLED |
(unset) | Enable the service proxy (1/true/yes/on) |
Literal IP addresses always pass the Host allowlist, so binding
0.0.0.0and connecting via the server's IP needs no extra config. To reach the daemon by hostname, add it toPI_STUDIO_HOSTNAMES.
Example — isolated home, custom port, password:
PI_STUDIO_HOME=/tmp/pi-studio-dev \
PI_STUDIO_LISTEN=127.0.0.1:6790 \
PI_STUDIO_PASSWORD=hunter2 \
node packages/server/dist/daemon/main.js
config.json
The persisted config is validated by a Zod schema (src/config/daemon-config.ts) with sane
defaults and .passthrough() tolerance for unknown/future keys. Notable sections:
{
"version": 1,
"daemon": {
"listen": "127.0.0.1:6767",
"hostnames": ["localhost", "*.localhost"],
"auth": { "password": "$2b$…bcrypt-hash…" },
"mcp": { "enabled": true, "injectIntoAgents": true },
"appendSystemPrompt": "",
"cors": { "allowedOrigins": [] },
"serviceProxy": { "enabled": false },
"relay": { "enabled": false, "endpoint": "relay-host:7000", "useTls": false }
},
"agents": {
"providers": {
"pi": { "command": ["/abs/path/to/pi", "--mode", "rpc"] }
}
},
"log": { "level": "info", "format": "json" }
}
To use a different pi binary than the bundled one, set
agents.providers.pi.command to an absolute path as shown above. Custom Pi-compatible profiles can
extend the pi provider via "extends": "pi" (a custom provider must also set a label).
Relay (opt-in, off by default): with daemon.relay.enabled: true, the daemon dials outbound
to the endpoint (a self-hosted @av-pi-studio/relay server or Cloudflare Workers deployment)
after the WS server is up, so remote clients can reach it without an inbound port. See
@av-pi-studio/relay's README for running a relay (pi-studio-relay bin / pi-studio relay start). Direct WebSocket connections are completely unaffected either way — the relay only adds
an additional connection path.
The wire API
All communication rides a single WebSocket connection per client.
Text frames — JSON envelopes discriminated by type
hello(Client→Server, first frame) — handshake withclientId,clientType(mobile/browser/cli/mcp),protocolVersion, optionalcapabilities.status(Server→Client) —server_infopayload sent right after a successful hello.ping/pong— JSON liveness (not RFC 6455 ping, which browsers/RN can't send).session— the envelope wrapping every RPC request/response/broadcast ({ type: "session", message }).rpc_error— a correlated error response (carries the originatingrequestId).
A non-hello first frame closes the socket. RPC names follow a dotted convention —
domain.provider.operation.direction (e.g. agent.permission.respond.request); legacy flat names
are accepted via aliases but never generated.
Binary frames — terminal + file transfer
Layout: [1-byte opcode][1-byte slot][payload]. The slot (0–255) demultiplexes multiple
terminals over the one connection. Codecs use Uint8Array (not Node Buffer) so they run
unchanged in browsers and React Native. File downloads also ride binary frames: a client
requests a token via the file_download_token_request RPC, then streams Begin → Chunk* → End
frames via file_download_request; uploads consume the same frame format.
HTTP surface
The HTTP server is intentionally minimal. Beyond liveness, its only application route in production is the service proxy (reverse proxy to localhost services started by agents).
| Route | Auth | Purpose |
|---|---|---|
GET /api/health |
none | Liveness — { "status": "ok" } |
OPTIONS * |
none | CORS preflight (204) |
| other paths | bearer | Delegated to the service proxy; 404 if unmatched |
The request pipeline: health + preflight are exempt; then Host-allowlist (403 on mismatch), CORS
headers, optional bearer auth (401), then application routes (404 if unmatched).
The schemas are the single source of truth and are append-only: new fields are optional,
types are never narrowed, and fields/discriminants are never removed — so an older daemon can always
decode data written by a newer one. They live in @av-pi-studio/protocol.
Agent providers
The daemon resolves a provider id string to an AgentClient via the ProviderRegistry. The only
surface the rest of the daemon depends on is src/agent/provider-contract.ts:
AgentClient.createSession(config, ctx)→AgentSessionAgentSession.run(prompt, opts)— start a turn; events emitted viasubscribe(handler)AgentSession.startTurn(prompt, opts)— fire-and-forget turn start, returns{ turnId }AgentSession.interrupt()/close()/update(patch)AgentSession.importSession(...)— resume a provider-native session by its handleRunOptions.imagescarriesImageAttachment[](wire shape{ mimeType?, data? }, base64); the provider translates it into its native prompt-image format at the boundary.
pi (real)
- Spawns
pi --mode rpc(bundled, or a configuredcommand) and speaks strict JSONL RPC over stdin/stdout (PiRpcTransport). event-mapper.tsmaps raw Pi events (assistant_message,tool_call,turn_completed, …) into the normalizedAgentStreamEventstream.- Discovers models/modes via top-level
get_modes/get_modelsRPCs (no scratch session). - Prompt images:
startTurnconverts the wire shape{ mimeType, data }into Pi'sImageContentshape{ type: "image", data, mimeType }before thepromptRPC. - A literal
~incwdis expanded to the home directory before spawning. - A missing/unresolvable
pisurfaces as a cleanrpc_error("Pi provider unavailable…") rather than crashing the daemon.
mock (in-process)
Emits synthetic events on a small timer loop. No credentials. Used for smoke tests and CI.
Persistence
All state lives under $PI_STUDIO_HOME/. Every write goes through AtomicStore
(write-to-temp-then-rename) for crash safety.
config.json Daemon config (password hash, provider overrides, service proxy, …)
server-id Stable server identity
logs/ Rotating NDJSON log files (pino)
agents/
<sanitized-cwd>/
<agentId>.json Agent record (status, config, timeline seq, labels, …)
chat/rooms.json Chat rooms + messages
loops/<loopId>.json Loop records
schedules/<scheduleId>.json Schedule records
projects.json Project registry
workspaces.json Workspace registry
All entity schemas use .passthrough() and optional fields — unknown/future fields from a newer
daemon load silently, so there is no migration framework to maintain.
Architecture
src/
daemon/
main.ts Production entry: parse env, wire bootstrap.ts, listen, handle signals.
dev-main.ts Dev entry: wires dev-bootstrap.ts (all features, LAN bind).
bootstrap.ts Production handler wiring (full RPC surface, real provider, disk state).
dev-bootstrap.ts Dev handler wiring (local testing only).
orchestration-rpc.ts
agent/ Agent lifecycle, provider registry, timeline, permissions.
agent-manager.ts Single source of truth for agent state + FSM + persistence + broadcast.
agent-service.ts RPC handler wiring for agent operations.
provider-contract.ts AgentClient / AgentSession interfaces (the ONLY provider surface).
provider-registry.ts Register/resolve AgentClient by provider id.
timeline-store.ts Append/page/cursor the agent event log.
permissions.ts Park + resolve tool-call permission requests.
providers/pi/ Real Pi provider (spawn, JSONL transport, event mapper).
providers/mock/ In-process synthetic provider.
ws/ WebSocket server, per-connection Session, HandlerRegistry + frame router.
http/ HTTP server (/api/health, downloads), Host allowlist.
auth/ PasswordAuth (bcrypt + WS subprotocol bearer token).
config/ DaemonConfig (env + config.json merge) and per-project config.
persistence/ Zod entity schemas, JSON stores, AtomicStore.
terminal/ TerminalManager (PTY lifecycle, slot mux, snapshot, binary broadcast).
projects/ Workspaces, projects, git ops, worktrees, GitHub, reconciliation.
orchestration/ ChatService, ScheduleService, LoopService, cron.
files/ File explorer + chunked download token store.
proxy/ ServiceProxy + port registry for agent-started services.
logging/ Pino logger factory.
util/ Concurrency helpers.
Lifecycle FSM
AgentManager enforces initializing → idle ↔ running → error → closed. Every transition persists
the record and broadcasts agent_update to subscribers. Archiving soft-deletes (sets
archivedAt). On startup, running agents are recovered (crash recovery), and running loops are
recovered as stopped with an interruption log entry.
For a deeper subsystem reference, see AGENTS.md in this package and the specs under
clean-room-scope/.
Security model
- Host-header allowlist (
src/http/host-allowlist.ts) rejects requests whoseHostisn't allowed — DNS-rebinding protection. Literal IPs always pass;localhost/*.localhostare always allowed; add hostnames viaPI_STUDIO_HOSTNAMES. - Password auth (
src/auth/password-auth.ts) — optional. When a password is configured it is bcrypt-checked against either apasswordquery param on the WS upgrade URL or api-studio-bearer.<base64(password)>WS subprotocol. An unset password allows all connections (fine for a trusted localhost-only setup; set one before exposing the daemon beyond a trusted network). - Service-proxy auth bypass is intentional — the proxy route is deliberately not gated by daemon password auth (per spec).
- RPC timeouts are operation-level, never socket death — an
rpcTimeoutMsexpiry yields anrpc_error, it does not close or reconnect the WebSocket.
Logging
createLogger(name, opts) returns a pino logger that writes pretty output to stdout in
development and rotating NDJSON to $PI_STUDIO_HOME/logs/ in production. The level comes from the
log.level config key (default info) or LOG_LEVEL.
Development
npm test -- --project packages/server # run this package's Vitest suite
npm run typecheck # tsc -b across all packages
npm run lint # oxlint
npm run fmt:check # oxfmt --check
Tests are co-located as *.test.ts next to their source. Provider tests inject stub transports;
persistence tests use temporary directories; WebSocket tests use in-memory session stubs. Avoid
real wall-clock timers in tests — await real completion signals instead.
Key invariants
provider-contract.tsis the only provider surface. Never importproviders/pi/orproviders/mock/from outsideagent/.- Handler registration is explicit. Register handlers in
bootstrap.ts/dev-bootstrap.ts, not via auto-discovery. - Agent status changes only via
AgentManagertransitions — never mutate a record directly. - All entity + wire schemas use
.passthrough()and optional fields — newer data must load on older daemons. - The wire protocol is append-only. Never remove or narrow a field, never change a discriminant.
dev-bootstrap.tsmust not be imported bybootstrap.ts.- Binary frame codecs are cross-platform (
Uint8Array, no NodeBuffer). ~incwdis expanded server-side before it reaches a provider.