Skip to content

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 -authonly verifies 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:

curl https://agentwatch.sh | bash        # detects the gateway → installs your per-user daemon

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.yaml carries 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 (optionally sudo rm -rf /etc/agentwatch).
  • macOS: launchctl bootout gui/$(id -u)/com.agentwatch.gateway && rm ~/Library/LaunchAgents/com.agentwatch.gateway.plist.