Skip to main content

Pi server

The Pi server runs Pi coding-agent sessions on a task VM. The Gateway’s pi driver creates sessions, starts turns, and reads transcripts through it. The Tasks reference describes the driver. The source lives in apps/pi-server. To build, run, and check it, see Bun and Rust projects. Orbit runs Pi only on task VMs. The Gateway installs and starts it there; see Run Pi on a task VM. No host Node runs Pi for task agents.

Configure the server

The server reads command-line flags first, then the environment. Orbit’s systemd Processes pass only arguments, so every setting has a flag. The token has only a file flag, because arguments are visible to other users and are stored in the Process definition. The server refuses to start without a bind address and a token. The Gateway reaches the server at http://{wireguard_ip}:{ORBIT_PI_PORT} on the task VM, with the VM’s own token. ORBIT_PI_PORT defaults to 3774. By default the server accepts only subscription sign-ins. A provider signed in with an API key, including a key in the server’s environment, is not available until PI_SERVER_ALLOW_API_KEYS=1 is set or --allow-provider names it. A task VM names its one provider with --allow-provider. The binary also has pi-server login <provider> for a subscription sign-in. Task VMs do not use it, because CLIProxyAPI is their only model route. pi-server login anthropic is refused. The Gateway’s pi driver refuses every Claude model, also through CLIProxyAPI. See Claude is unavailable for task agents.

Run Pi on a task VM

On a task VM, the Gateway installs and starts Pi itself, in the PrepareTaskVmRuntime job. There is no manual install. Pi runs as the VM’s managed user orbit, which has passwordless sudo. A warm VM gets none of the steps below until a group claims it. The claim creates the VM’s Pi token, and the job then runs every step. The proxycli CLIProxyAPI URL must be task_vms.model_proxy_origin. Otherwise the job fails with task_vm.invalid_config before it creates a key.
  1. The Gateway creates a CLIProxyAPI key for the group. It stores the key and the origin on the task VM row first. Then it registers the key with the management key of the proxycli extension.
  2. It checks whether ~orbit/.local/bin/pi-server already has the SHA-256 digest task_vms.pi.artifact_sha256. A VM from a base image built with the same pin has it, so the Gateway skips the next step.
  3. Otherwise it sends the pinned Pi executable from task_vms.pi.artifact_path over the Node’s normal SSH connection, on standard input. The VM checks the digest and installs the executable as ~orbit/.local/bin/pi-server.
  4. It writes ~orbit/.pi/agent/models.json with the origin in task_vms.model_proxy_origin, the models in task_vms.pi.models, and the group’s key.
  5. It writes the task VM’s own Pi token to ~orbit/.pi/agent/orbit-token. Both files have mode 0600.
  6. It creates the normal pi-server Process: a systemd service as orbit on the VM’s WireGuard address and ORBIT_PI_PORT, with --allow-provider=orbit-task-vm, restart policy always, and keep-alive.
Each step is idempotent, so a retried job repeats it and keeps the key. models.json has one provider, orbit-task-vm, with the API openai-responses and the base URL <origin>/v1. Each model has reasoning: true. Pi’s defaults apply to the rest, such as a context window of 128,000 tokens. The pi driver sends every model to orbit-task-vm. The Gateway connects to this server with the task VM’s token and removes the group’s model key from transcripts as it does the token. CLIProxyAPI is the only model route, and no subscription sign-in enters the VM. Destroying the VM revokes the group’s key at the origin stored on the row.

Limits

An agent is a process of the Pi server and shares its user, orbit. It can read the VM’s Pi token in ~orbit/.pi/agent/orbit-token and the group’s model key in models.json. Both are scoped to this VM and this group, and destroying the VM revokes the key. The agent has passwordless sudo inside the VM. The VM edge is the boundary; One user for every task agent explains why. The VM holds no GitHub token: the Gateway fetches and pushes for it, see Git on a task VM.

Agent tools

A Pi session has Pi’s read, bash, edit, and write tools and one Orbit tool, search_docs. search_docs searches the Laravel ecosystem documentation through Laravel Boost. Results match the package versions installed in the Project, so the agent uses framework features that exist in those versions. Pi has no MCP client; the tool sends only the MCP messages this search needs. The tool chooses the Laravel app in this order:
  1. The project directory, when the agent passes it.
  2. The session workspace, when it has an artisan file.
  3. The only apps/* directory with an artisan file and vendor/laravel/boost.
When several apps/* directories match, the tool asks for project. The app needs laravel/boost installed, so run composer install in it first. The tool runs php artisan boost:mcp in the app with php from the server’s PATH. It calls Boost’s search-docs tool over MCP on standard input and output and returns the text. Boost runs only in a local or debug app, so the tool sets APP_DEBUG=true when the server’s environment does not set it. The server’s PI_SERVER_* variables stay out of that process. Agent bash and documentation lookup processes inherit the workspace’s agent TMPDIR, separate from the setup/check runner’s directory; task checks describes the layout. A non-Git session uses <workspace>/.orbit/tmp/agent-<uid>. Boost fetches the documentation from boost.laravel.com, so the Node needs outbound HTTPS. The tool stops Boost after the answer, after 60 seconds, or when the turn is interrupted. A failed search returns an error result to the agent. The error names the cause, such as no Laravel app, Boost not installed or not enabled, or a timeout.

Large tool output

A read or bash result larger than 8 KiB does not enter the model context. The server writes the full text to a file and stores a short notice in the session. The stream shows that notice. edit, write, search_docs, and an image read are unchanged. 8 KiB is 8,192 UTF-8 bytes. read measures the selected lines when the call sets offset or limit, and the whole file otherwise. bash measures stdout and stderr in the order the tool read them, without the exit line. A result of 8,192 bytes or fewer is returned in full. There is no line cap on that result. The file is new, under the session workspace at .git/orbit/tool-output/. The server creates the directory with mode 0770 when .git is a directory and the process can create it. The file mode is 0660. Its name starts with the tool, the session id, and the tool call id. A second result does not replace an earlier file. The file contains the measured text only. The notice uses the absolute path. Those group bits keep a workspace ACL in force, so every user that the ACL names can read the file and delete it. The server does not change the mode of a directory it does not own. The notice is at most 8,192 bytes:
read previews the first 20 lines. bash previews the last 40. Lines split on newline. A final newline does not add a line. When the preview would make the notice larger than 8,192 bytes, whole lines drop from the end of a read preview or the start of a bash preview. A line is not split. The file keeps it. The first line then adds Preview shows N lines. A bash notice ends with Exit code: N when the process exits, including 0. An abort ends with Command aborted. A timeout ends with Command timed out after N seconds. A non-zero exit, an abort, and a timeout stay failed tool results. The failure text is the notice. The files stay on the Node until the workspace clone is removed. Idle unload and a server restart leave them in place. They are not pushed and not copied off the Node. Git ignores paths inside .git, so they stay out of diffs and the review workspace tree. A workspace whose .git is not a directory returns the full text and writes no file. Reading the saved file follows the same rule. A slice of 8,192 bytes or fewer returns in full. When the file cannot be written, the result is an error: the byte count, the line count, and the reason. The error does not include the output. A bash error ends with the same exit, abort, or timeout line as a notice. The read and bash descriptions tell the model about this limit. They name .git/orbit/tool-output/, the path, the counts, and the short preview.

API

Every route requires Authorization: Bearer <token>. Errors return {"error": {"code", "message", "details"}}, the same envelope as the Gateway. Each code is namespaced by family, such as session.not_found. A failure the Gateway also has uses the Gateway’s code, such as http.404 and validation.failed. No error has details yet, so details is an empty object. Older Pi server binaries send older error shapes. A binary built before this envelope answers {"error": {"code", "message"}} with underscore codes, such as session_not_found. A short-lived later build answers http.not_found with "details": null. The Gateway only quotes the code in its error message, so every binary works with it. Task VMs run the artifact the Gateway pins. To roll out a new binary, update ORBIT_TASK_VMS_PI_ARTIFACT_PATH and ORBIT_TASK_VMS_PI_ARTIFACT_SHA256. The Gateway uploads the changed artifact when it prepares a task VM, so the base image does not need a rebuild. The turn ID is the key of the latest accepted send. The Gateway uses a new key for each turn and reuses it when it retries a send.

Token usage

The snapshot and each stream state event include usage. The sums cover every assistant message that has numeric input, output, cacheRead, and cacheWrite. A message without that usage is not a call. Thread token metrics describes how the Gateway stores these numbers on the agent thread. Orbit stores uncached input as input + cacheWrite and cached input as cacheRead. Per-call usage in the session file uses the same input, cacheRead, cacheWrite, and output fields. totalTokens on a call equals those four numbers added.

Stream

Every stream event carries run and sequence. The run is a random ID that the server picks each time it loads a session. Sequence numbers start at 0 with each run and grow with each event. A transcript entry is streamed when Pi writes it. Pi writes an assistant message with a tool call when the call starts, and the tool result when the call ends. Without a cursor, the stream starts with a snapshot event, the same as GET /sessions/{id}. To resume, pass the last event the client received as ?run={run}&after={sequence}. When the run is the session’s current run and the sequence is not ahead of it, the stream starts with a resumed event:
Then it sends the entries streamed after that sequence and the latest state event if it came after it, in sequence order. context lists the entries at or before the cursor whose tool calls have no result yet, so the client can name the results that follow. The server sends a snapshot instead when the cursor is missing or malformed, names another run, or is ahead of the run. A cursor from before a restart or an idle unload names another run.

Thread states

The server reports one of the Orbit thread states from its own evidence. The Gateway stores that state on the thread. Pi has no approvals, so a Pi thread never asks for input. A new turn changes a done or failed state to working.

Restarts

Transcripts persist as Pi session files. After a restart, the server reloads a session when it is first used. Accepted send keys persist, so a repeated send after a restart still starts no turn. The server records a turn as active before it starts and clears the record when the turn settles. A record still marked active after a restart reports failed with the error The Pi server restarted during the turn. The Gateway resumes that turn. Recover a Pi server restart states how.

Roll out a new binary

Pin the new artifact with ORBIT_TASK_VMS_PI_ARTIFACT_PATH and ORBIT_TASK_VMS_PI_ARTIFACT_SHA256; Run Pi on a task VM shows how the Gateway installs it. A task VM gets the binary when the Gateway prepares it at claim. A VM that is already prepared keeps its binary until it is destroyed, so no running turn restarts.

Test worker file access

Run bun run test in apps/pi-server on Linux with setfacl and passwordless sudo -n -u nobody available. Many tests call setfacl to set up workspaces as a Node does, so they fail without it. The worker ACL test writes tool output as nobody inside a temporary checkout owned by the test runner, then checks that the checkout owner can read and rewrite it. The test copies the Bun runtime and the tool-output module into its temporary fixture. It invokes the runtime by absolute path with a restricted PATH and uses the fixture as its working directory. This keeps the worker independent of sudo’s executable search path and of access to the test runner’s home or source checkout. Cleanup removes both copies and the checkout; the test does not change home permissions or sudo policy.

Why it works this way

These reasons explain the design. Check them before you propose a change.

Claude is unavailable for task agents

Anthropic permits Claude subscription credentials only in its own applications, also when a proxy such as CLIProxyAPI relays them. So the pi driver refuses a Claude model, pi-server login anthropic refuses to sign in, and task agents have no other runtime that runs Claude. Tasks: Task agents run on Pi explains that choice. Annotations still use the operator’s T3 threads. They are not task agents.

A long-lived server, not RPC over SSH

A Pi process started over SSH would live only as long as that connection, and a reconnect would lose the live event stream. Reading session files over SSH gives history without live events. So one server on the Node owns the sessions and serves snapshots and streams.

The server owns the stream cursor

The server already holds the transcript and its order, so it resumes a stream after a cursor. A cursor in the Gateway would need a store of what each browser has seen. A snapshot on every connection would cost more with every conversation and every open tab.

Large tool output goes to a file

A large tool result fills the model context, and each later call sends that text again. Cutting the result would lose the rest for good. So a large result goes to a file that a later turn can read. The file sits inside .git, so diffs and the review tree never include it. When the file cannot be written, the result is an error, because inlining it would bring back the cost. The file mode keeps a workspace ACL in force, so every user that the ACL names can delete the file.

One user for every task agent

On a task VM, one user, orbit, runs Pi, the agents, the setup steps, and the baseline and handoff checks. That user has passwordless sudo, so the checks can run host-dependent tests, such as Orbit’s own Gateway tests that need setfacl and the caddy account. The VM edge is the boundary. A second user inside the VM would protect nothing that the VM does not already protect. The VM holds no GitHub token and no subscription sign-in. This serves security fits the real threat model. Task groups once shared host Nodes, and agents ran there as a separate account without sudo. That account kept agents away from the managed user’s SSH login and token-bearing Git. It also made the agents’ environment differ from the checks: tests that need sudo failed for agents and passed in the gate. Every task group now runs on its own task VM, so Orbit deleted the shared mode and that account. Run each task group in its own sandbox VM records the decision.