Install the binaries
Each release shipsoh (the CLI) and ohd (the daemon) as
self-contained executables — the bundled runtime, the daemon, the web
app, and the compiled SQLite addon are all inside the binary. No
Node.js required.
- macOS
- Linux
- Docker
~/.local/bin. Binaries currently ship for Apple silicon
(mac-arm64).<state dir>/sea/<build>/ — a native module can only load from disk —
with checksum-verified, crash-safe unpacking; set
OH_DAEMON_UNPACK_DIR to move that base.
The service lifecycle
ohd status prints
where to point that browser. See First run.
ohd install writes a user service unit — a launchd LaunchAgent
on macOS, a systemd user unit on Linux — and persists the config flags
it is given into daemon.json. Re-run it with new flags at any time
to reconfigure: an omitted flag keeps its persisted value
(--no-trusted-proxy / --no-allow-insecure-lan clear the booleans),
and ohd restart applies the result. ohd start is a no-op while the
service already runs.
On Linux, install also enables the unit for boot and turns on user
lingering (systemctl --user enable oh-daemon.service,
loginctl enable-linger) so the daemon survives reboots and outlives
the SSH session that installed it; if either command needs privileges,
the exact manual command is printed instead. On macOS the LaunchAgent
starts at login.
Foreground instead
ohd run runs the daemon in the foreground — it is exactly what the
service unit execs. Ctrl-C / SIGTERM shuts it down cleanly. Handy
under container supervisors and for trying things out before
installing a service unit.
Where everything lives
Move the data dir with
--data-dir (persisted like every install
flag). Everything the daemon persists lives under it.
Configuration precedence
Precedence, highest first: argv → env →daemon.json → defaults.
daemon.json is the durable configuration — ohd install writes the
flags it is given into it, the service unit carries only --config,
and every ohd command (status, show-token, the daemon boot
itself) reads the same file.
Runtime settings are separate
Runtime settings live instorage.json, not daemon.json. The CLI
exposes the MCP switches and the update switch, all off by default:
mcp.enabled, mcp.allowObserve, mcp.allowWrite,
mcp.allowExecute, mcp.allowSecrets, updates.autoUpdate, and
backend.allowRemotePeerExecute — whether devices that joined the
server may run their requests on it (on by default; the same switch
sits under Server Admin › Server). Bind and
network options are not settings — they persist through the
ohd install flags above. config set refuses while the daemon runs
(storage.json is single-writer); a running daemon takes settings
changes from a connected admin surface instead. Reads work anytime.
The web app
The daemon serves the Open Headers web app — the same Workbench UI the desktop app and extension run — as static files on its bind. Browsers only run it from a secure origin, so where you open it depends on the machine:
The Workbench needs the browser’s cryptography APIs (
crypto.subtle) to
mint its identity, and browsers withhold them on any plain-HTTP origin
that is not loopback. That rule is the browser’s and it binds the web
app only: the browser extension and the desktop app connect over
ws://<server>:8137 from anywhere on the network without TLS, which
is how a headless server is normally used — no browser on the server
required.
Distributions built with the web bundle serve it out of the box; point
--web-root at a different built bundle to serve that instead. An
explicitly configured web root must contain an index.html, or the
daemon refuses to boot; without any web root the daemon runs
headless-only and / answers 400.
Next
- The server has no administrator until a browser claims it: First run.
- Making it reachable beyond loopback is a deliberate step: LAN vs TLS proxy.
- Clients join with a token: Tokens & pairing.