Skip to content

Sandbox workers

This page is the operator reference: messages, leases, drain, and which process needs KVM. Why workers exist and how a turn moves is in Workers. Isolation of Pi is in isolation.

Trusted ApiPi workers host Firecracker. They are not customer self_hosted runners. self_hosted is an untrusted computer that a tenant attaches with a per-session key on /v1/environments/{id}. Workers use a different path, a different secret, and different messages.

Firecracker, jailer, TAP, and the guest live on the worker. apipi serve --api-only never probes /dev/kvm and never creates a TAP device. Combined apipi serve (no --api-only) is the single-host embedded worker: the same in-process adapter as today, for a laptop or one box. Production is API-only plus one or more apipi worker hosts. Chat fleets add workers with APIPI_RUN_MODE=chat next to microvm. See chat fleets.

apipi worker requires APIPI_WORKER_TOKEN and probes the configured run mode before it connects. If APIPI_RUN_MODE=microvm cannot start, the worker exits. It does not fall back to none.

apipi serve --api-only (or APIPI_API_ONLY) runs turns on a leased worker. The API persists events from the store and streams SSE without Pi on that node. If no worker can take a lease, the turn returns 429 with code capacity. Combined apipi serve still runs turns in-process.

Start everything through the ApiPi CLI:

apipi serve
apipi serve --api-only
APIPI_WORKER_TOKEN=secret APIPI_API_URL=http://api.example:8000 apipi worker
apipi check --role api
apipi check --role worker
apipi install --role api
apipi install --role worker

Combined apipi serve keeps today's single-host path. --api-only skips the KVM probe so the API can run without Firecracker. apipi worker is the sandbox process. It is not a self_hosted runner.

Auth

The worker opens an outbound WebSocket to /internal/worker and sends Authorization: Bearer <token>. The token is APIPI_WORKER_TOKEN on the API process. The gateway compares it in memory. It does not store worker secrets in Postgres. If the token is unset, the socket is rejected.

This is not mTLS yet. A later change can add it without changing the message types.

Messages

JSON objects. The first worker message must be register.

Worker to API:

type Fields What
register id (optional UUID), capacity (int ≥ 1), memory_mb (int ≥ 1, optional), run_mode (string, required), images (optional list) Create or reconnect the worker. capacity is max live sessions. memory_mb is the RAM budget in MiB. If memory_mb is omitted, the API uses capacity × guest mem_mib. run_mode is the placement class this process serves (chat, microvm, or the process APIPI_RUN_MODE). images lists {id, version, digest, min_size} for guest images on this host. An older microvm worker that omits images is treated as having default and browser. Reconnect bumps generation so a split brain cannot keep both sockets.
heartbeat capacity (optional), memory_mb (optional), run_mode (optional), drain (optional bool), images (optional list) Refresh last_seen. May update caps, advertised run_mode, drain posture, and the image list.
lease.ack id (command id), lease_id Command was received. Retransmits of the same id are safe.
lease.release session_id, lease_id Worker dropped the session.
event lease_id, event_type, data Persist a public session event. The worker must hold that lease. Unknown event types are ignored.

API to worker:

type Fields What
hello ok, worker_id, generation Register succeeded.
command id, session_id, lease_id, op, payload op is turn.start, turn.cancel, turn.continue, or session.stop.
lease.revoke session_id, lease_id Lease is no longer valid.
error object ok: false, error Auth or register failed, then the socket closes.

Leases

A lease is durable on the session row (worker_id, lease_id, lease_until). Grant is a single conditional UPDATE: it only succeeds when there is no live lease. Heartbeats extend all of that worker's leases in one statement. Commands carry lease_id. A worker that does not hold that lease cannot ack, emit events, or release it.

When lease_until passes, API processes expire rows with FOR UPDATE SKIP LOCKED so two reapers do not double-clear. The API clears ownership, emits agent.session.error with code worker_lease_expired, and sends lease.revoke if the worker is still connected. It does not assign the session to another worker in this version.

workers.api_instance_id is the APIPI_INSTANCE_ID of the API process that currently holds that worker's WebSocket. Register and heartbeat write it. Detach clears it only if it still matches this process. If a turn needs that worker but this process has no socket, the API returns 429 with code capacity and names that instance. There is no cross-API command forwarding. Point each worker at the API that will dispatch its turns, or stick /internal/worker to one API. SSE and session create stay store-backed on any replica.

Reconnect with the same worker id replaces the old socket, increments generation, and retransmits unacked commands for leases that worker still owns. The same command.id is replayed; the worker must treat that id as idempotent so a turn is not run twice.

Placement

WorkerHub.pick matches placement class before capacity or RAM. A session is assigned only to a connected worker whose advertised run_mode equals that class. There is no fallback to another mode.

Session Required worker run_mode
Session metadata apipi.session_kind=chat (/v1/chat) chat always
Agents with a computer (openai_hosted, hosted, or self_hosted) microvm
Agents with environment.type=none APIPI_ENV_NONE_PLACEMENT / [placement].env_none: chat (default), microvm, or reject

reject fails the turn with 400 and code placement. No matching worker is 429 with code capacity, as today.

Commands include run_mode in the payload. The worker compares that to its process APIPI_RUN_MODE and does not start Pi when they do not match. APIPI_RUN_MODE=chat is the process name for a chat pool. It uses the same host backend as none. Isolation none may still run a chat command. A none or chat worker must not run a microvm command, and a microvm worker must not run a chat command.

Set APIPI_RUN_MODE=chat on dedicated chat workers so they advertise chat. Advertising none matches no Agents placement class.

Idle reap

Idle Pi reap and hosted workspace wipe run on the process that holds Pi. Combined apipi serve starts those loops in the API process. apipi worker starts the same loops. apipi serve --api-only does not kill idle guests; the worker that owns the session does. none and self_hosted use APIPI_IDLE_TTL. Hosted computers use APIPI_SANDBOX_TTL_OPENAI_HOSTED. A host Pi kill increments apipi_pi_kill_total with reason idle on the worker metrics endpoint.

Drain and expiry

A heartbeat may include "drain": true. That worker keeps its current leases and heartbeats them, but the scheduler does not give it new sessions. After the placement filter, it picks among workers that are not draining, have a free session slot, and have enough remaining memory_mb for one more guest (mem_mib from [sandbox.resources], default 512). Among those it prefers the worker with the most free RAM. Session count is only a filter and a tie-break.

Session delete sends session.stop to the worker that holds the lease. The worker kills that guest and deletes host files it owns, then acknowledges. The API drops the lease only after that acknowledgement. A delete does not wait for idle TTL.

SIGTERM or SIGINT on apipi worker sends that drain heartbeat, kills idle Pi (sessions not in a turn), waits until no live Pi remain, then exits 0. In-flight turns finish first. If live Pi remain after --drain-timeout (default idle TTL, 15 minutes), the process exits 1 and systemd may then SIGKILL the cgroup. systemctl stop and systemctl restart send SIGTERM. Raise TimeoutStopSec so stop can wait; the example drop-in is deploy/systemd/apipi-worker-drain.conf (TimeoutStopSec=16min). Copy it to /etc/systemd/system/apipi-worker.service.d/drain.conf. The default unit keeps TimeoutStopSec=15 so a Firecracker stop still fails fast unless you install the drop-in.

When lease_until passes, the lease is cleared and the session gets worker_lease_expired. The turn is not moved to another worker: the guest and workspace were on the expired host. Start a new turn after that error. Heartbeats extend lease_until so a live worker does not expire mid-turn.

Host Pi (chat / none) is a child of the worker. A graceful stop runs pool teardown. A kill -9 of the worker leaves those children. The next worker start reaps leftovers stamped with a dead APIPI_WORKER_PID. Set KillMode=control-group on the systemd unit (deploy/systemd/apipi-worker.service) so systemctl stop kills the cgroup. See production.

What runs where

Process Trust Needs
apipi serve --api-only Operator control plane Postgres, worker token, no KVM
apipi worker Operator sandbox host KVM, Firecracker, worker token, outbound to the API
Combined apipi serve Lab / one box Whatever the run mode needs, including KVM when microvm
self_hosted runner Tenant computer Per-session key on /v1/environments/{id}

The worker token is an operator secret. It is not a tenant bearer and is not stored in Postgres. Do not put it in the browser. The API container in Compose is unprivileged. The worker unit is the only place that should receive /dev/kvm and CAP_NET_ADMIN.