Architecture & Installation (single- and multi-user)¶
agentwatch runs one architecture on every box: a gateway — the "main service" that owns the Cloudflare tunnel, TLS, login/auth, the full HTTPS API, web viewer, state store, detection, hook policy, and session-name allocation — plus one or more slim per-user daemons that execute tmux/Claude sessions as the user (no HTTP server, no auth, no state). Single-user is simply N=1 of the multi-user shape. There is no separate "monolith" mode.
┌─────────────────────────────────────────┐
internet ────▶│ gateway (the "main service") │
(CF Tunnel) │ tunnel · TLS · auth · web viewer │
│ full API · state · detection · hooks │
│ session routing · name allocation │
└───────────┬─────────────────────────────┘
│ Unix socket per user (AF_UNIX,
│ mode 0600, kernel peer-cred)
┌───────────────────┼──────────────────────┐
▼ ▼ ▼
per-user daemon per-user daemon per-user daemon
(uid A: tmux/spawn) (uid B: tmux/spawn) (uid C: tmux/spawn)
Linux (shared server): N per-user daemons (systemd units)
macOS (personal mac): N=1 per-user daemon (LaunchAgent)
Privilege model — the gateway runs as root only where it must¶
The gateway's privilege is a function of multi-user, not of the gateway role:
- Linux, multi-user: the gateway runs as root — it must authenticate
arbitrary OS users (PAM needs privilege), own the gateway-wide runtime dir
(
/run/agentwatch), and run as a system unit. - macOS, single-user: the gateway runs as the logged-in user — none of
those needs exist (
dscl -authonlyverifies the user's own password unprivileged, the runtime dir is user-owned). Install needs no sudo; both gateway and daemon are user LaunchAgents.
Same gateway code either way; only the privilege level and the service manager
differ. Socket and runtime-file locations come from one seam
(agentwatch/runtime_paths.py):
- Linux: per-daemon sockets at /run/agentwatch/users/<uid>.sock (0600);
per-user runtime dir at /run/user/<uid>/agentwatch (or $XDG_RUNTIME_DIR).
- macOS: single daemon socket + gateway runtime at
~/.cache/agentwatch/run/daemon.sock (note: $TMPDIR is not used because
launchd, tmux subprocesses, and shells receive different $TMPDIR values).
Installation — one command, autodetected¶
curl https://agentwatch.sh | bash (or ./install.sh) detects the situation and does
the right thing. There are no --multi-user / --gateway flags.
| You run the installer… | It installs… |
|---|---|
| on macOS | the gateway and your per-user daemon, both as user LaunchAgents — no sudo. |
on Linux as root (sudo ./install.sh) |
the system gateway only. Users self-onboard next. |
| on Linux as a normal user, gateway present | your per-user daemon, registered with the gateway. |
| on Linux as a normal user, no gateway | it refuses with a hint: an admin must install the gateway first. |
| re-run where a gateway already exists | an idempotent update in place. |
The decision matrix is implemented in install.sh (decide_install_role) and
mirrored/tested in agentwatch/install/detect.py.
macOS (personal machine)¶
curl https://agentwatch.sh | bash # installs gateway + per-user daemon (no sudo)
agentwatch setup # device token → tunnel; gateway restarts itself into TLS
(agentwatch config set token <jwt> is the scriptable equivalent; apply it
with agentwatch restart.)
Both LaunchAgents live in ~/Library/LaunchAgents/
(com.agentwatch.gateway.plist, com.agentwatch.plist). For a headless
always-on mac, load them into the user's GUI session with
launchctl bootstrap gui/$(id -u) <plist> (the launchd analog of systemd
lingering); the installer does this for you.
Linux (shared server)¶
1. Admin stands up the gateway (once):
curl https://agentwatch.sh | sudo bash # installs + starts the system gateway unit
sudo agentwatch setup # device token → tunnel; gateway restarts itself into TLS
2. Each user onboards themselves:
For the unstable channel (latest green main), use
curl https://agentwatch.sh/unstable | bash (or | sudo bash for the gateway)
in place of the one-liners above — mixing channels between the gateway and
daemons is not recommended.
This writes ~/.config/agentwatch/config.yaml (service.role: user), enables
lingering (loginctl enable-linger; falls back to a sudo hint if the session is
not active), and starts systemctl --user --now agentwatch. The daemon dials the
gateway's Unix socket (/run/agentwatch/users/<uid>.sock) to register; the
connection itself is the registration — no registry files are written.
Security model¶
Per-user daemons have no HTTP server; they are reachable only by the gateway
over a Unix socket. Trust is established at the kernel level: on accept, the
gateway reads the peer's effective UID via SO_PEERCRED (Linux) or
LOCAL_PEERCRED (macOS) and verifies it matches the socket owner. No bearer
tokens, no TLS, and no certs are used on the internal hop. On Linux each
per-daemon socket lives at /run/agentwatch/users/<uid>.sock (mode 0600, owned
by the daemon's user), unreadable by peers. On single-user macOS the socket is at
~/.cache/agentwatch/run/daemon.sock (0600, user-owned); there are no peers.
Concurrent logins (one browser, several users)¶
One operator can be logged into several per-user daemons at once in a single
browser on the gateway origin. Visit /login again and sign in as another user
— the login is appended, not replaced. The session picker aggregates across
every user you're logged into (each session shows an owner badge), and opening a
session routes to its owning user's daemon automatically. POST /api/logout
with {"username": "<user>"} logs out one user; with no body, everyone.
Restart / update continuity (Restart Rule #1)¶
agentwatch restart preserves the Cloudflare tunnel and live web logins across a
restart (the new gateway adopts the still-running cloudflared). This relies
on KillMode=process (systemd) / AbandonProcessGroup (launchd) in the service
units so the manager does not cascade-kill cloudflared. Re-running
./install.sh --update refreshes the units and preserves this.
Update channel (gateway-governed)¶
The self-update channel — stable (latest tagged release) or unstable (latest
green main) — is a gateway-only setting. It lives in the gateway config
(repo_monitors.auto_update.channel in /etc/agentwatch/gateway.yaml on Linux,
~/.config/agentwatch/gateway.yaml on macOS) and governs the whole box:
- The gateway self-updates its own tree when its channel advertises a newer
build, then runs the graceful
orchestrated_restart. - Per-user daemons have no channel of their own. On (re)connect the gateway
compares versions and, if a daemon is behind, hands it the gateway's exact
channel + version to self-update to — so a daemon always converges to whatever
the gateway runs. A daemon cannot pick or drift its own channel, and its
generated
config.yamlcarries no channel knob.
To move a box between channels, set the channel in the gateway config and run
agentwatch restart. Note that repo_monitors.auto_update (the release channel)
is gateway-only, whereas repo_monitors.managed_repos (the poll-and-pull CD
feature) is the separate daemon-side setting that lives in each user's
config.yaml.
Verify¶
curl -sk https://localhost:8081/health # {"s":"ok", ...}
agentwatch health # daemon + tunnel end-to-end
agentwatch status # sessions, resolved via the gateway
Uninstalling the gateway¶
- Linux:
sudo systemctl disable --now agentwatch-gateway && sudo rm /etc/systemd/system/agentwatch-gateway.service && sudo systemctl daemon-reload(optionallysudo rm -rf /etc/agentwatch). - macOS:
launchctl bootout gui/$(id -u)/com.agentwatch.gateway && rm ~/Library/LaunchAgents/com.agentwatch.gateway.plist.