Headless host
peers-headless runs the Peers runtime in a Node.js process: no Electron, no
window, no UI. It is a first-class App host. One user per process.
Use it as an always-on personal node, a CI / agent target, or a second device in scripted tests. The Electron app, PWA, and hosted k8s device are separate hosts; they do not import this package yet.
A headless process can also hold a System Local Folders
grant for a directory that should stay available while laptops sleep. Pass
--folder (below) or peers fs grant after it is up. This phase records the
grant and can index the folder on this machine. Sending those blocks to other
devices is a later phase.
Start
cd peers-headless
npm run build
# Ephemeral (in-memory SQLite), no cloud services
npx peers-headless --new-user --db :memory: --print-credentials --services-url none
# Persistent
npx peers-headless --new-user --db ~/peers/headless/alice --name Alice
# later launches reuse <db>/credentials.json
npx peers-headless --db ~/peers/headless/alice
With an on-disk --db, --new-user writes { userId, secretKey } to
<db>/credentials.json (mode 0600) and refuses to overwrite an existing file.
Explicit --user-id/--secret-key, --credentials <file>, or
USER_ID/SECRET_KEY override it. --name only applies on first boot; a
rename from another device is kept. peers app restart re-execs as the same
identity.
Pair instead of copying a secret
To add a headless host to an account you already use, do not copy the secret
key around. Start it signed out with --pair:
npx peers-headless --pair --db ~/peers/headless/server --name "Home server"
It creates a rendezvous room on --services-url (default https://peers.app)
and prints a code such as 7-guitar-revenge-tunnel, plus a QR code when stdout
is a terminal. On a signed-in device choose Settings → Add another device
and scan or type the code, or on a signed-in host run
peers pair 7-guitar-revenge-tunnel. When you approve, the headless process
receives the credentials over a direct WebSocket, writes
<db>/credentials.json (mode 0600), and continues booting as that account in
the same process. The secret never appears on argv, in the environment, or in
logs. Later launches need only --db.
While pairing, the process listens on --peer-port (default 3341) for the
signed-in device and advertises http://<lan-ip>:<port> and
http://127.0.0.1:<port>; the normal device-mesh listener takes the port over
once pairing completes. Only a socket presenting the PAKE-derived token for the
current code is admitted, and only one. If the host sits behind a proxy or TLS
terminator, advertise the public origin instead:
npx peers-headless --pair --db /srv/peers --pair-url https://peers.example.com
--pair refuses to run when <db>/credentials.json, explicit credentials, or
USER_ID/SECRET_KEY already provide an identity, and it needs a
--services-url (not none) for the rendezvous. Each line of progress is
printed; PAIRING_CODE <code> is a machine-readable marker for scripts. A
failed attempt rotates to a new code; if the runtime had already initialized
when the failure happened, the process exits non-zero and should be started
again. Details of the ceremony are in Add another device.
The signed-in side is available too: peers --auth-file ~/peers/cli/headless-auth.json pair <code>
approves a new device from a headless host.
The process binds a frontend socket on 127.0.0.1 and writes
~/peers/cli/headless-auth.json (port + token). That path is separate from
Electron's ~/peers/cli/cli-auth.json, so a running desktop app is not stolen.
Point the CLI at this host without hijacking Electron:
peers --auth-file ~/peers/cli/headless-auth.json db query "SELECT name FROM Users"
PEERS_CLI_AUTH_FILE is the same override. --write-cli-auth still replaces
the default Electron auth file if you want that.
--port 0 binds an ephemeral frontend port. --peer-port 0 binds an
ephemeral device-mesh port and --peer-host 127.0.0.1 restricts it to
loopback (default is every interface). --no-db-access disables
SELECT/PRAGMA over the socket (allowed by default on a headless host).
--no-peer skips the mesh listener.
A machine-readable READY {json} line is printed on stdout when the host is
up (userId, deviceId, port, token, authFile, peerPort, webrtc,
services).
Registering with peers-services
A device needs an account token from peers-services before it can use the
mailbox (store-and-forward for invites while the other party is offline). On
the desktop and PWA the welcome screen asks for it; a headless host asks with
--register-services (or PEERS_REGISTER_SERVICES=1):
npx peers-headless --db ~/peers/headless/server --register-services
After the device initializes it calls the same registerWithPeersServices RPC
the UI uses and stores the token in the encrypted PEERS_SERVICES_TOKEN user
variable, where MailboxClient reads it. Registration is best effort: a
failure logs [services] registration ... failed; continuing without a mailbox token and startup continues. The READY payload reports services as off
(flag not given or --services-url none), registered, or failed. The flag
refuses to run together with --services-url none.
The flag is only the opt-in. Once a token is stored, the runtime keeps it
alive on every host (headless, desktop, PWA): it re-authenticates a week before
the 30-day expiry and once more if the mailbox answers 401, so a long-running
host does not need --register-services on later starts. A host that never
registered is left alone.
WebRTC (optional)
Device-to-device WebRTC runs in a separate Go process, peers-webrtc, the same
sidecar the desktop app ships. It is an optional dependency: peers-headless
does not bundle the binary, and a host without one (or with one that keeps
crashing) boots and forms WebSocket edges exactly as before. WebRTC adds the
paths a plain listener cannot cover: two devices that both sit behind NAT with
no reachable --peer-port, and a pairing destination that a source cannot
dial directly.
Discovery order at startup:
--webrtc-sidecar <path>(orPEERS_WEBRTC_SIDECAR): use exactly this binary. If it does not exist the host logs one line and continues without WebRTC.- Otherwise auto-detect: the sibling dev build for this platform
(
../peers-webrtc/bin/<goos>-<goarch>/peers-webrtc), the legacy un-suffixed../peers-webrtc/bin/peers-webrtc, thenpeers-webrtconPATH. --no-webrtc: never look for or start a sidecar (webrtc: "off").
Build the binary once with cd peers-webrtc && make local (needs Go); the
monorepo checkout then auto-detects it. Nothing is started when --no-peer is
given, since there is no mesh to take part in.
Status shows up in three places. The startup log prints [WebRTC] Starting sidecar <path> then [WebRTC] sidecar ready, or a single [WebRTC] ... continuing with WebSocket only line naming the flags when nothing was found.
The READY payload carries webrtc: off, starting, ready, down, or
unavailable. And NetworkManager only advertises wrtc to peers while the
sidecar is authenticated (isAvailable()), so a host whose sidecar is starting
or gone never invites a 30 s WebRTC timeout from the other side.
Failure handling is bounded. A sidecar that exits before authenticating or
within a few seconds of spawning counts as a failed start; restarts back off
(2 s, 4 s, ... 60 s) and after five consecutive failures the host stops
retrying, logs [WebRTC] sidecar unavailable; continuing with WebSocket only,
and keeps running. A sidecar that ran healthily and then died is restarted
with the counter reset.
Scope on loopback: two headless hosts on one machine exercise the signaling
path, the datachannel state machine, and sync over wrtc://; they do not
exercise STUN/TURN or NAT traversal. If both sides dial at once, the device
with the lexicographically smaller deviceId keeps its offer and the other
answers it ([Sidecar] WebRTC glare ... in the logs). Without that rule each
side dropped its own offer and the wrtc:// edge never formed.
The connection manager applies the same ordering after both sockets have
finished the handshake, on every transport. The smaller deviceId keeps its
outbound connection and the other device keeps that connection's inbound side
(Keeping the deterministic connection to … after dial glare). Without that
shared choice each endpoint keeps its own outbound socket and closes the
other, so both links go down and redial backs off.
What it does
- Calls
initializePeerDevicewithDBLocal(file or:memory:) - Encrypts secret persistent variables and decrypts them for workflows
- Loads system tools and installs
peers-corefrom the local bundle (S3 only when--services-urlis notnone) - Registers the Node isolated-package runtime
- Captures console output into
ConsoleLogs - Serves the same socket.io RPC + system contracts the CLI already uses,
including
addOrUpdatePackage:peers packages add <name|url|id>installs, imports, clones, or copies packages exactly as on Electron. The installer itself lives in the Runtime (peers-device'spackage-install); headless gives it plain Node filesystem and shell deps. Remote-repo linking (--link-remote) is Electron-only because it needsgit/gh - Listens for device connections (
--peer-port, default 3341) and can dial explicit peers (--peer http://127.0.0.1:3342) - Registers the WebRTC sidecar as a second protocol manager when a
peers-webrtcbinary is found (see WebRTC); the sidecar itself lives in the Runtime (peers-device'sWebRTCSidecar) and is shared with the desktop app - Dials devices it discovers, not only the ones on argv: a
wsprotocol manager letsNetworkManagerconnect to own devices found in the syncedDevicestable, peers learned from another device's network info, and group admins on join. Addresses come fromDevices.serverUrland from asking the target (get-ws-addressesover the mesh); the target answers with what it advertises:http://127.0.0.1:<peer-port>plus its LAN address, or the--advertise-urlorigins when given (hosts behind a reverse proxy or NAT) - Device pairing on both sides:
--pairas the signed-out new device (direct WebSocket transport),peers pair <code>as the signed-in approver - Logs unhandled promise rejections instead of exiting, like Electron's main process, so a peer disconnecting mid-sync cannot take the host down
- One process, one user — spawn another process for a second device
Offline / test flags
| Flag | Purpose |
|---|---|
--services-url none | No mailbox, no peers.app discovery |
--register-services | Register with --services-url at startup and store the mailbox token (PEERS_REGISTER_SERVICES=1). Incompatible with --services-url none |
--no-peer | Do not listen for other devices |
--peer-host 127.0.0.1 | Mesh listener on loopback only |
--peer <url> | Connect to a known peer (repeatable) |
--advertise-url <url> | Origin other devices dial to reach this listener instead of the detected addresses (repeatable) |
--lan-scan | Scan the LAN for Electron peers on port 3333 |
--max-connections <n> | Override the device connection cap (default 30). A testing knob: a small cap reproduces at-capacity shedding with a handful of devices (peers-e2e's cap.e2e.test.ts uses 4) |
--webrtc-sidecar <path> | Use this peers-webrtc binary instead of auto-detecting (PEERS_WEBRTC_SIDECAR is the same) |
--no-webrtc | Never look for or start the WebRTC sidecar; WebSocket edges only |
--folder <groupId>=<absolutePath>[:ro|:rw] | Grant a local directory at startup (repeatable). :ro is read-only; the default is readwrite. The same group and real path are reused on later startups. Headless has no folder picker. See System Local Folders. |
Testing with it
Multi-device scenarios live in peers-e2e; see
End-to-end fleet testing. It spawns fleets of real headless
processes (up to 100), wires them into a mesh over loopback, and drives each
through its RPC socket, with per-device logs, mesh snapshots, and fault
injection. npx peers-fleet up from that package stands up the same fleet
detached for interactive or agent use.
peers-headless itself keeps only the spawn helpers and a runtime smoke suite.
@peers-app/peers-headless exports spawnHeadlessProcess and runPeersCli:
each spawned host is a separate process (in-memory SQLite, ephemeral ports,
loopback mesh, --services-url none) that prints READY {json}; the CLI is
pointed at it with --auth-file. Children run without NODE_ENV=test so the
runtime behaves like a real device. Set PEERS_HARNESS_DEBUG=1 to mirror every
child's output to the test's stderr. In peers-headless: npm test (unit +
in-process smoke), npm run test:live (real hosts + CLI), npm run test:all.
For pairing, spawnPairingHeadlessProcess starts a --pair host against a
given --services-url and resolves with its code (ready resolves with the
READY payload once a source approves). There is no stand-in rendezvous any
more: peers-e2e's pairing.e2e.test.ts runs the real peers-services as a
fleet child and pairs two real headless processes through it.
Not yet
- UI / static file serving
peers://protocol handling- Shipping the
peers-webrtcbinary withnpx peers-headless(a platform-specific optional package or a release download); today you build it or point--webrtc-sidecarat one - Pairing over WebRTC:
--pairstill uses the direct WebSocket transport, so a headless destination needs a source that can reach one of its advertised URLs