Provisioning runs on a systemd Linux host only. The commands refuse to run anywhere else, because
each member’s gateway is a systemd service. Members themselves can connect from macOS, Windows,
iOS or Android.
How the isolation works
Every member gets four things of their own:
The boundary is an operating system user rather than a token check inside one shared process, so the kernel enforces it. One member’s gateway cannot read another’s memories, sessions, plans or credentials, and a bug in the plugin cannot leak them across profiles.
Each unit runs with systemd hardening applied:
NoNewPrivileges, PrivateTmp, RestrictSUIDSGID, LockPersonality, RemoveIPC, and the ProtectKernel* and ProtectControlGroups family. Writable paths are bound to the member’s home directory and nothing else.
Before you start
You need root on the host, a shared Hermes virtual environment, and the plugin installed into it:HERMES_VENV_PYTHON at its python3.
Configure the gateway’s own Hermes profile first, at /root/.hermes by default. New members inherit their model provider and credentials from it, so provisioning against an unconfigured profile produces members whose assistants cannot answer.
Install Tailscale on the host if members will connect from their own laptops or phones. Without it, provisioning still succeeds, but each member is reachable only from the host itself.
Provisioning a member
- Validates the label. Labels must match
^[a-z0-9][a-z0-9-]{0,30}$and must not collide with a reserved system account name such asroot,daemon,dockerorhermes. - Creates the OS user, with a locked password, a
nologinshell and a home directory at mode0700. - Builds the profile skeleton under
/home/alice/.hermes, includingmemories,sessions,skills,plans,workspaceandplugins, each owned by the member at mode0700. - Writes the member’s
.envwith a freshly generated 48 character relay token, their derived listen port and their home channel. - Seeds the model provider from the gateway’s own profile, as described under what members inherit.
- Installs the plugin shim into the member’s own
pluginsdirectory. - Renders and starts the systemd unit, then verifies it is genuinely serving.
- Publishes the port to your tailnet with
tailscale serve, and confirms it appears intailscale serve status. - Prints the member’s pairing code and QR, generated by running the CLI as that member against their own profile.
Why the verification step matters
A systemd unit that is crash-looping reportsactive in the moment between restarts. Checking systemctl is-active on its own would report success for a gateway that is not actually serving anyone.
Provisioning therefore waits for three conditions together: the unit is active, its restart count is still zero, and the derived port is genuinely accepting connections. It polls for up to 20 seconds, and if the conditions are not met it fails with the exact journalctl command to run.
What members inherit, and what they do not
Members inherit only what they need to reach a model. Four configuration keys are copied from the gateway’s profile:modelfallback_providersfallback_modelmodel_catalog
auth.json is written, and only the environment variables the surviving credentials reference are copied into their .env.
Nothing personal crosses the boundary. Memories, sessions, chat integrations, skills and plans all stay with the profile that owns them.
How members reach their gateway
Each member’s port is derived arithmetically from their UID:8765 and 100. Deriving the port rather than allocating one means it stays the same across reprovisioning and never needs to be recorded anywhere. If the derived port is already held by a process the tool does not manage, provisioning stops instead of quietly taking it.
The relay URL is resolved in priority order:
--urlif you passed one, used verbatim.--relay-host HOST, which becomeshttps://HOST:PORT.- The host’s own tailnet DNS name, detected automatically.
http://127.0.0.1:PORTas a last resort, with a warning that the member cannot connect from their own devices.
STAGEWHISPER_ALLOW_INGRESS_HOSTS, so a loopback trial never widens what the listener accepts.
Approving the first connection
The first time a member connects, their assistant refuses to answer and prints an approval code instead. Approve it as that member, not as root:runuser wrapper here. Each member has their own Hermes profile, so a bare hermes pairing approve run as root looks in root’s profile and reports the code as not found. The provisioning output prints this command with the member’s real paths already filled in.
Revoking a member
.env, which removes their relay token. Deleting the token is what makes revocation survive a restart: a unit re-enabled later has no credential to serve with.
Revocation leaves the OS user, the home directory and all Hermes state in place. Cutting off access and destroying someone’s data are separate decisions, and only the first one can be undone. Remove the user by hand if that is what you want.
Re-running provisioning
provision is safe to re-run for an existing member. An existing relay token is read and preserved rather than regenerated, so a member who has already paired does not have to pair again. Configuration, the plugin shim and the unit are all refreshed.
Two guards apply. Provisioning refuses to adopt an OS user that already exists but has no matching unit file, so it cannot take over an unrelated system account. It also refuses to continue if a member’s .env exists but contains no relay token, since it cannot tell a partially written file from one you edited on purpose.
Reference
Commands
Provisioning options
Per-member environment
Troubleshooting
The member’s assistant accepts the pairing but never answers. This is almost always missing provider credentials. Re-read the provisioning output for a warning about the credential pool, fix the gateway’s own profile, then re-runprovision for that member.
The approval code is reported as not found. You ran hermes pairing approve as root instead of through runuser with the member’s HERMES_HOME.
The member cannot connect from their laptop. The relay URL fell back to loopback, which happens when the host is not on a tailnet or you passed a loopback --url. Install Tailscale and re-run, or pass --relay-host.
tailscale serve reports that HTTPS is disabled. Enable HTTPS certificates for your tailnet in the Tailscale admin console under DNS, then re-run.
The unit will not stay up. Provisioning prints the exact command to use: