Hermes Agent installs from a shell script in a couple of minutes. Installing it the same way twice, a year apart, on a machine you have since forgotten the details of, is the harder problem — and it is the one Ansible solves.
This article builds one role that installs Hermes on two Macs that differ in the ways that actually matter:
devbot5 — the Mac you are typing on | minime — a headless Mac mini | |
|---|---|---|
| Connection | local, no SSH at all | ssh |
| Runs as | your login account | a dedicated service account |
| launchd job | LaunchAgent in your home | LaunchDaemon in /Library |
| API bound to | 127.0.0.1 | 0.0.0.0 |
Those last two rows are what this article is about. A headless Mac has no one logged in, so there is no GUI session for a LaunchAgent to live in and the job has to be a LaunchDaemon that starts at boot and drops privileges. A laptop joins hotel and coffee-shop networks, so binding an agent’s API to every interface there would publish a shell to whoever else is on that LAN.
Everything else is shared, and the role keeps it shared: the installer, the
login PATH, the environment file, the health check. The differences live in
two small group_vars files rather than in two forked roles.
devbot5 and minime are just the names of my two machines. They appear in
the inventory, in a handful of --limit flags, and in the sample output;
nowhere else. Substitute your own hostnames as you go — the inventory in Step 3
is the only file you have to change, and everything downstream follows from it.
minime is also a plain hostname resolvable on my LAN rather than anything
special; use an IP address or a .local name if that is what reaches your mini.
And you do not need two Macs. The two halves are independent, so take whichever you have:
- Laptop or desktop only. Keep the
workstationsgroup, delete theheadlessgroup and its host from the inventory, and skip Step 11. You can dropinventory/group_vars/headless.ymltoo, though leaving it costs nothing. - Headless mini only. Keep the
headlessgroup, deleteworkstationsand its host, and skip Step 10 — or read it anyway, since it is where the dry run, the lint gate, and the idempotence check are explained, and those apply to either machine. - Both, later. Adding the second machine is one host entry in the right group. That is the point of splitting the settings this way rather than hard-coding them per host.
Where a step applies to only one kind of machine, it says so in its first sentence.
This is the core install. The production extras that usually surround it (a Traefik route, an Open WebUI frontend, the Slack gateway, CLI wrappers for other local accounts) are deliberately out of scope, and the recap says where they go.
Versions used throughout: ansible-core 2.21, ansible-lint at its
production profile, Hermes Agent v0.20.5 on macOS 26.5.2 (Apple M5 Max) and
v0.20.4 on macOS 26.6.1 (Apple M4).
Prerequisites
- One Mac or two. Both halves stand alone; see the note above on dropping
either. The names used throughout,
devbot5andminime, are mine — swap in yours. - Ansible on the machine you drive from:
brew install ansible ansible-lint. - SSH key access to the headless Mac, with passwordless
sudofor the account you connect as — only if you are doing the mini half.ssh <user>@<host> sudo -n truemust succeed; the LaunchDaemon is written to a root-owned directory and nothing else needs root. - The Xcode Command Line Tools on both Macs. The Hermes installer clones a
git repository, and
gitis what a bare Mac lacks:xcode-select --install. - Roughly 2 GB free per Mac. The installer provisions its own Python 3.11
and Node under
HERMES_HOMErather than using yours; the install measured here came to 1.9 GB. A fresh install lays down Node 26, though the floor it will actually accept is 22.22.0, so a machine with a managed Node already in that range keeps it. It also wants ripgrep and ffmpeg, but those it does not bundle: if they are missing it shells out tobrew install, so they land in/opt/homebrewlike any other formula, and if Homebrew is absent it only warns.
Familiarity with Ansible’s vocabulary (inventory, role, task, handler) helps, but every file is shown in full.
If you have not run Hermes by hand yet, Getting Started with Hermes Agent on macOS covers the installer, the model provider, and the safety settings this article automates around. Read it first if you want to understand what is being installed; read this one to stop doing it by hand.
Step 1: What actually differs between a laptop and a headless mini
Before writing any YAML it is worth being precise about the differences, because there are only three of them and each has a wrong answer that looks fine.
launchd has two job types and they are not interchangeable. A LaunchAgent
runs in a user’s GUI session: it starts when that user logs in and dies when they
log out. A LaunchDaemon is loaded by launchd as root before any login
happens, and stays up as long as the machine is on. On a workstation the
LaunchAgent is right — you want the agent running while you are, and stopping
when you are not. On a headless mini nobody ever logs in, so a LaunchAgent
either never starts or stops the moment an SSH session ends. It has to be a
LaunchDaemon.
A LaunchDaemon starts as root, which is not what you want an autonomous agent
running as. The fix is two keys, UserName and GroupName, which tell launchd
which account to drop to before exec. For a LaunchAgent those keys do nothing:
launchd.plist(5) says UserName “is only applicable for services that are
loaded into the privileged system domain”, and adds that “for agents, the
UserName key is ignored”. Ignored, not rejected — an agent carrying it still
loads without complaint. That is the reason to emit it conditionally rather than
always: a plist that looks like it drops privileges on both machines, while
actually doing so on only one, is worse than one that never claimed to.
A laptop’s network is not a LAN you control. The mini sits behind a router at
a fixed address, and the entire point of it is that other devices reach the
agent, so binding to 0.0.0.0 is intent rather than sloppiness. A laptop moves.
Binding an agent’s OpenAI-compatible API to every interface in an airport
exposes it to everyone else on that network, so the workstation stays on
127.0.0.1. A role whose default opens the API unless told otherwise has the
wrong default.
ansible_connection: local is not the same as SSH to yourself. You can point
Ansible at your own machine over SSH, but it means enabling Remote Login and
managing a key just to configure the laptop you are sitting at. The local
connection skips the network entirely. It is a one-line inventory difference, and
the role never has to know which one it is running under.
Two things that are not differences, and are worth saying because they look like they should be. Both Macs run the same installer with the same flags. Both put binaries in the same place. The role is one role, not two with a shared prelude.
Step 2: Scaffold the project
The .gitignore goes in before anything else. This project generates an API key
in Step 8 and writes a vault password to disk, and the window in which a
repository has secrets but no ignore rules is exactly when a reflexive
git add . happens.
Create the files
mkdir -p ~/hermes-ansible
cd ~/hermes-ansible
mkdir -p inventory/group_vars group_vars/all playbooks
mkdir -p roles/hermes/{defaults,tasks,handlers,templates}
touch .gitignore ansible.cfg
Add the code: .gitignore
# Ansible vault password — the one file that must never be committed.
# The encrypted vault itself is safe in git; this is what decrypts it.
.vault-pass
*.vault-pass
# In YOUR repository, commit group_vars/all/vault.yml: an encrypted vault in
# git is the point, and it is what makes the repo a complete description of the
# host. It is excluded here only because this is a published tutorial — a vault
# whose passphrase appears in the article is decryptable by every reader, and
# shipping one invites somebody to reuse it. Run `make vault-init` to create
# your own.
group_vars/all/vault.yml
# Decrypted secrets, if you ever dump them to inspect
*.decrypted
*.plain.yml
# Hermes environment files rendered locally for debugging. The real one is
# written straight to the target host, but it is easy to render one here while
# working on the template.
.env
*.env
# Ansible runtime noise
*.retry
.ansible/
fact_cache/
# Python virtualenv, if you install Ansible into one rather than via Homebrew
.venv/
__pycache__/
# macOS
.DS_Store
Detailed breakdown
.vault-passis the entry that matters. The encrypted vault is safe to commit, which is the point of encrypting it, but the passphrase that opens it is not. Committing both is the same as committing neither.*.envcatches a rendered environment file. The real one is written straight to the target host, but it is easy to render one locally while debugging a template and then forget it holds a live key..ansible/is the fact cache configured in the next file. It is machine state, not source.
Add the code: ansible.cfg
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
# Off because the mini is a LAN host that gets reimaged; on a machine you do not
# re-key, leave this on. It is the one convenience setting here that trades away
# a real check, so it is called out rather than inherited silently.
host_key_checking = False
retry_files_enabled = False
deprecation_warnings = False
# Facts are gathered per-run; caching them makes a second run noticeably faster
# without changing what it does.
gathering = smart
fact_caching = jsonfile
fact_caching_connection = .ansible/fact_cache
fact_caching_timeout = 86400
# Read by the vault tasks. Keep this file out of git — see .gitignore.
vault_password_file = .vault-pass
[privilege_escalation]
# The LaunchDaemon lives in /Library/LaunchDaemons, which is root-owned. Every
# other task in the role runs unprivileged and says so explicitly.
become = False
become_method = sudo
become_user = root
[ssh_connection]
pipelining = True
Detailed breakdown
become = Falseis deliberate, and it is the security posture. Most macOS Ansible examples setbecome = Trueglobally, which runs every task as root — including the installer, which would then drop a root-owned Python runtime into a user’s home. Here the default is unprivileged and exactly two tasks opt in, both of them writing to/Library.vault_password_filepoints at the file.gitignorejust excluded, soansible-playbookandansible-vaultboth find the passphrase without a prompt. On a shared machine preferansible-vault --vault-id, or a script that reads from the Keychain, over a file on disk.pipelining = Truecuts the number of SSH round trips per task. It is safe here becausebecomeis off by default; pipelining conflicts withrequirettysudo configurations, which is the usual reason people disable it.fact_cachingmakes the second run of a two-host play noticeably faster without changing what it does.
Step 3: Describe both Macs in one inventory
The inventory is where the two machines stop being special cases. Each host gets the two or three lines that are genuinely about it, and everything else is inherited from the group it belongs to.
Create the file
touch inventory/hosts.yml
Add the code: inventory/hosts.yml
---
# Two Macs, two connection styles, one role.
#
# ansible-playbook playbooks/install-hermes.yml --limit devbot5
# ansible-playbook playbooks/install-hermes.yml --limit minime
#
# `macs` is the group the role attaches to. The split below it is not
# decoration: `workstations` and `headless` differ in what is safe to expose,
# which group_vars encodes rather than leaving to memory.
all:
children:
macs:
children:
workstations:
hosts:
devbot5:
# The machine you are typing on. No SSH, no network round trip,
# and no sshd to enable just to configure your own laptop.
ansible_connection: local
ansible_user: mitch
# Pinned for the same reason as the mini. Left unset, Ansible
# discovers whichever python3 is first on PATH — a Homebrew one
# here — and warns that a future install could change it. Pinning
# makes both Macs behave identically.
ansible_python_interpreter: /usr/bin/python3
headless:
hosts:
minime:
ansible_host: minime
ansible_connection: ssh
ansible_user: serviceuser
# The system python3 on macOS is a stub that prompts to install
# the Command Line Tools. Point at the real interpreter so a
# module never triggers that dialog on a machine with no screen.
ansible_python_interpreter: /usr/bin/python3
Detailed breakdown
- The nesting is doing real work.
macsis what the playbook targets, so the role attaches once.workstationsandheadlessexist so that the settings which differ have somewhere to live that is named after why they differ. A third Mac joins by being put in the right group, and inherits the correct launchd and network behaviour without anyone remembering to set them. ansible_connection: localon the workstation. No sshd, no key, no loopback round trip.ansible_python_interpreter: /usr/bin/python3on both, and the reason is determinism rather than safety. Left unset, Ansible discovers whicheverpython3is first onPATH, warns that a future install could move it, and can behave differently on the two machines for no reason. Be aware of what you are pinning to:/usr/bin/python3is thexcode-selectshim, the very binary that pops the Command Line Tools dialog when the tools are absent. That is survivable here only because the Prerequisites already require the CLT on both Macs, and on a headless machine that dialog is a prompt nobody will ever click. It also resolves to Python 3.9.6, at the bottom of what ansible-core 2.21 supports on a target.
Step 4: Put the differences where they explain themselves
Two settings differ between the machines, and both are decisions someone will
question later. Putting them in files named workstations.yml and
headless.yml means the answer to “why is this Mac bound to all interfaces”
is the filename.
Create the files
touch inventory/group_vars/all.yml
touch inventory/group_vars/workstations.yml
touch inventory/group_vars/headless.yml
Add the code: inventory/group_vars/all.yml
---
# Applies to every Mac in the inventory. Anything that differs per machine
# belongs in inventory/host_vars, and anything that differs per *class* of
# machine belongs in group_vars/workstations.yml or group_vars/headless.yml.
#
# Only hermes_installer_url lives here. The port is a role default: duplicating
# a value across two precedence layers means editing the one in `defaults` has
# no effect, which is a confusing thing to leave for later.
hermes_installer_url: "https://hermes-agent.nousresearch.com/install.sh"
Detailed breakdown
hermes_installer_urlis the URL the role downloads and executes. It is here rather than in the role’s defaults on purpose, which Step 5 explains.hermes_api_portis shared because both machines run the gateway on the same port; only the interface it binds to differs, which is what the two files below are for.
Add the code: inventory/group_vars/workstations.yml
---
# A laptop joins coffee-shop and hotel networks. Binding an agent's API to all
# interfaces there would publish a shell to whoever else is on the LAN, so the
# workstation profile is loopback-only and the difference is deliberate.
hermes_api_host: "127.0.0.1"
# A workstation has a logged-in user, so the agent can run as a LaunchAgent in
# that user's session and stop when they log out. See group_vars/headless.yml
# for why the mini cannot do this.
hermes_service_scope: "agent"
Detailed breakdown
hermes_api_host: "127.0.0.1"is the whole workstation security posture in one line. A laptop’s network is not one you control.hermes_service_scope: "agent"picks a LaunchAgent, which starts at login and stops at logout. On a machine with a human at it, that is the behaviour you want.
Add the code: inventory/group_vars/headless.yml
---
# The mini is a fixed LAN appliance behind a router, and the whole point of it
# is that other devices can reach the agent. Binding to all interfaces is the
# intent here, not an oversight — which is exactly why it is written down in a
# file named `headless` rather than left as a default someone inherits.
hermes_api_host: "0.0.0.0"
# No one is logged in, so there is no GUI session for a LaunchAgent to live in.
# A LaunchDaemon starts at boot regardless and drops to an unprivileged account
# via UserName. This single value is the real difference between the two Macs,
# and Step 6 is about why.
hermes_service_scope: "daemon"
Detailed breakdown
inventory/group_vars/, notgroup_vars/. This one cost me a failed run. Ansible auto-loadsgroup_vars/only when it sits beside the inventory or beside the playbook. Agroup_vars/at the project root, adjacent to neither, is silently ignored — there is no warning, no “unused directory” notice; the variables simply come out undefined and the first task to reference one dies with'hermes_installer_url' is undefined. Since the inventory here isinventory/hosts.yml, itsgroup_varsbelongs atinventory/group_vars/.- The root
group_vars/all/vault.ymlin Step 8 is the deliberate exception. The playbook pulls it in with an explicitvars_files, because it is not auto-loaded for a playbook — the basedir there isplaybooks/. Be precise about the scope of that claim: an ad-hocansibleoransible-inventorycommand has no playbook and uses the working directory as its basedir, so run from the repository root those do pick the vault up, and will happily decrypt and print it. Keeping the encrypted file visibly separate from the plaintext ones is still worth one line of playbook. hermes_service_scopeis read in one place and then never again. The twoset_facttasks at the top of the role turn it intohermes_plist_pathandhermes_needs_root, and everything downstream works from those: the handler’sbecome, and the plist template’s one conditional. Resolving a branch once and naming the result is what keeps a second, subtly different copy of the ternary from appearing later.- Precedence, so the layering is predictable: role defaults lose to
group_vars/all, which loses to a group file, which loses tohost_vars, which loses to-eon the command line. That ordering is why the role can ship a safe127.0.0.1default thatheadless.ymloverrides without either file knowing about the other.
Step 5: Give the role its defaults
A role’s defaults/main.yml is its documentation and its safety floor. Every
variable that has a safe default is declared here, set to the most cautious
value, so a host that overrides nothing still gets a contained install.
Two are deliberately absent. hermes_api_server_key comes from the vault, and
hermes_installer_url lives in group_vars — defaulting a URL the role fetches
and executes would mean a misplaced group_vars silently installs from
somewhere rather than failing. Step 10’s troubleshooting entry is that failure
happening.
Create the file
touch roles/hermes/defaults/main.yml
Add the code: roles/hermes/defaults/main.yml
---
# Hermes Agent (Nous Research) — native macOS install, no Docker.
# Docs: https://hermes-agent.nousresearch.com/docs/getting-started/installation
#
# Everything here is overridable from group_vars or host_vars. The values that
# actually differ between a laptop and a headless mini are hermes_api_host and
# hermes_service_scope, both set in group_vars rather than here.
# The account the agent runs as. This must BE the account Ansible connects as:
# every file task below runs unprivileged, and chown-ing to a different user
# needs root. Setting this to anything other than ansible_user makes those tasks
# fail rather than quietly installing for someone else. It exists as a variable
# so the tasks read clearly, not as a knob to turn.
hermes_user: "{{ ansible_user }}"
hermes_user_home: "/Users/{{ hermes_user }}"
# HERMES_HOME. The installer puts its private Python, Node and tooling here, and
# .env lives here too — hence mode 0700 in the tasks.
hermes_home: "{{ hermes_user_home }}/.hermes"
hermes_bin: "{{ hermes_user_home }}/.local/bin/hermes"
# launchd needs somewhere to send stdout/stderr. Under HERMES_HOME rather than
# /tmp: the gateway logs prompts and tool output, and /tmp is world-readable on
# a shared machine.
hermes_log_dir: "{{ hermes_home }}/logs"
# The installer drops binaries into two directories and exports PATH only
# inside its own shell, so a later `ssh minime hermes --version` fails with
# "command not found" unless we wire these in ourselves.
hermes_path_dirs:
- "{{ hermes_user_home }}/.local/bin"
- "{{ hermes_home }}/bin"
# All three matter, and for different reasons:
# .zshenv — sourced by NON-interactive shells, which is what `ssh host cmd`
# and most Ansible modules get. This is the one that fixes SSH.
# .zprofile — login shells
# .zshrc — interactive shells
hermes_shell_profiles:
- .zshenv
- .zprofile
- .zshrc
# Re-run the installer over an existing install: it does a git pull --ff-only
# and re-resolves dependencies. Without this the role is a no-op once installed.
# ansible-playbook playbooks/install-hermes.yml -e hermes_force_update=true
hermes_force_update: false
# launchd. "daemon" writes /Library/LaunchDaemons (root-owned, starts at boot,
# drops privileges with UserName). "agent" writes ~/Library/LaunchAgents (starts
# when that user logs in). group_vars picks one per class of machine.
hermes_service_scope: "agent"
hermes_service_label: "com.homelab.hermes"
# Where the OpenAI-compatible API server listens. Loopback by default: a role
# that exposes an agent to the LAN unless told otherwise is the wrong default.
hermes_api_host: "127.0.0.1"
hermes_api_port: 8642
# The gateway imports the whole agent runtime at boot, which is slower than a
# container start. 24 x 5s.
hermes_health_retries: 24
hermes_health_delay: 5
# `launchctl unload` returns as soon as SIGTERM is sent, so a reload can race
# the old process for the port. Seconds to wait for it to clear.
hermes_restart_drain_retries: 30
Detailed breakdown
hermes_userdefaults toansible_user. The agent runs as whoever Ansible connected as:mitchon the workstation,serviceuseron the mini. That is almost always what you want, and it is overridable when it is not.hermes_shell_profileslists three files, and.zshenvis the one that earns its place. It is the only file a non-interactive shell sources, which is whatssh minime hermes --versiongets, so without it the command works when you log in and fails from automation — a maddening way to lose an hour. The installer has PATH logic of its own, but it never touches.zshenv, and the breakdown in Step 7 explains why it does not touch the other two either under this role.hermes_service_scope: "agent"is the safe default for the same reason the API host is loopback: if a new host is added and nobody thinks about it, the outcome should be the more contained one.hermes_log_dirsits underHERMES_HOMErather than/tmp. The gateway logs prompts and tool output, and/tmpis world-readable.hermes_force_updateexists because the version check in Step 7 makes the role a no-op once Hermes is installed. Without an escape hatch there would be no way to upgrade.
Step 6: One template, two kinds of launchd job
A single Jinja template emits either a LaunchAgent or a LaunchDaemon, and the difference between the two machines comes down to four lines in it.
Create the file
touch roles/hermes/templates/launchd.plist.j2
Add the code: roles/hermes/templates/launchd.plist.j2
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{{ hermes_service_label }}</string>
<key>ProgramArguments</key>
<array>
<string>{{ hermes_bin }}</string>
<string>gateway</string>
<string>run</string>
<string>--external-supervisor</string>
</array>
{% if hermes_service_scope == 'daemon' %}
{# A LaunchDaemon is loaded by launchd as root before anyone logs in, so it
must be told which account to drop to. A LaunchAgent already runs as the
user who owns it and rejects these two keys. #}
<key>UserName</key>
<string>{{ hermes_user }}</string>
<key>GroupName</key>
<string>staff</string>
{% endif %}
<key>WorkingDirectory</key>
<string>{{ hermes_user_home }}</string>
{# launchd starts processes with a nearly empty environment. Nothing here
is inherited from a shell, so HOME and PATH have to be stated. #}
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{{ hermes_user_home }}</string>
<key>HERMES_HOME</key>
<string>{{ hermes_home }}</string>
<key>PATH</key>
<string>{{ hermes_path_dirs | join(':') }}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
</dict>
<key>RunAtLoad</key>
<true/>
{# --external-supervisor makes an in-chat restart exit non-zero on purpose;
KeepAlive is what brings the gateway back after that. #}
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{{ hermes_log_dir }}/hermes-gateway.log</string>
<key>StandardErrorPath</key>
<string>{{ hermes_log_dir }}/hermes-gateway.err</string>
</dict>
</plist>
Detailed breakdown
- The
{% if hermes_service_scope == 'daemon' %}block is the entire workstation-versus-mini difference as far as launchd is concerned. A LaunchDaemon is loaded as root and must be told which account to drop to. An agent already is that user, andlaunchdignores the key there rather than erroring, so emitting it unconditionally would not break the workstation. It would do something worse: leave a plist that reads as though it drops privileges when on that machine it does nothing at all. EnvironmentVariableshas to be there.launchdstarts processes with a nearly empty environment: noPATH, noHOME, nothing from any shell profile. ThePATHwired into.zshenvin Step 7 is for your shell; this block is what the service itself gets, and the two are unrelated. A plist that omits it produces a job that exits immediately with a “command not found” that never reaches a terminal.KeepAlivepairs with--external-supervisor. That flag makes an in-chat restart exit non-zero on purpose, andKeepAliveis what brings the gateway back afterwards. Together they mean “restart” works from inside the agent.RunAtLoadstarts the job when it is loaded and at every boot, which is what makes the mini survive a power cut without anyone SSH-ing in.
Step 7: Write the tasks
The role’s job is to be boring on the second run. Every task below either declares a state Ansible can compare against reality, or is gated behind a check that makes it skip when there is nothing to do.
Create the files
touch roles/hermes/tasks/main.yml
touch roles/hermes/handlers/main.yml
Add the code: roles/hermes/tasks/main.yml
---
# Resolve the two scope-dependent paths once, so no task below has to branch on
# hermes_service_scope. A LaunchDaemon is root-owned under /Library; a
# LaunchAgent belongs to the user under their own home.
- name: Set launchd paths for this host's service scope
ansible.builtin.set_fact:
hermes_plist_path: >-
{{ '/Library/LaunchDaemons/' ~ hermes_service_label ~ '.plist'
if hermes_service_scope == 'daemon'
else hermes_user_home ~ '/Library/LaunchAgents/' ~ hermes_service_label ~ '.plist' }}
hermes_needs_root: "{{ hermes_service_scope == 'daemon' }}"
- name: Set launchctl commands for this host's service scope
ansible.builtin.set_fact:
hermes_launchctl_load: "launchctl load -w {{ hermes_plist_path }}"
hermes_launchctl_unload: "launchctl unload {{ hermes_plist_path }}"
# Fail on the vault before doing anything slow. A first install takes minutes;
# discovering a missing key afterwards wastes all of it.
- name: Verify the API key is available from the vault
ansible.builtin.assert:
that:
- hermes_api_server_key is defined
- hermes_api_server_key | length >= 32
fail_msg: >-
hermes_api_server_key is missing or too short. It comes from
group_vars/all/vault.yml — run `make vault-init` to create it, or
`make vault-edit` to fix it. Anyone holding this key gets a shell on
this host as {{ hermes_user }}, so it is not a formality.
- name: Fail early on anything that is not macOS
ansible.builtin.assert:
that: ansible_facts['system'] == 'Darwin'
fail_msg: "This role targets macOS. {{ inventory_hostname }} reports {{ ansible_facts['system'] }}."
# The installer clones its own repository, so git has to exist first. On a bare
# Mac it does not, and the failure it produces on its own is unhelpful.
# noqa command-instead-of-module: ansible.builtin.git manages repositories.
# This is a probe for whether the binary exists at all, which that module
# cannot answer without being handed a repo to clone.
- name: Check for git, which the Hermes installer requires
ansible.builtin.command: git --version # noqa: command-instead-of-module
register: hermes_git_check
changed_when: false
failed_when: false
- name: Fail when git is unavailable
ansible.builtin.fail:
msg: "git is required by the Hermes installer. Run: xcode-select --install"
when: hermes_git_check.rc != 0
# This is the idempotence gate for the whole role. If the launcher answers, the
# expensive block below is skipped entirely.
- name: Check the installed Hermes version
ansible.builtin.command: "{{ hermes_bin }} --version"
register: hermes_version_check
changed_when: false
failed_when: false
environment:
HERMES_HOME: "{{ hermes_home }}"
- name: Install or update Hermes Agent
when: hermes_version_check.rc != 0 or hermes_force_update | bool
block:
- name: Download the Hermes installer
ansible.builtin.get_url:
url: "{{ hermes_installer_url }}"
dest: /tmp/hermes-install.sh
mode: "0755"
force: true
# Several minutes on a first run: it provisions a private Python 3.11 and
# Node under HERMES_HOME, and installs ripgrep and ffmpeg through Homebrew
# if they are missing. --skip-setup suppresses the interactive wizard, which
# would otherwise hang the play waiting on stdin that never comes.
#
# Node 26 is what a fresh install *installs*; the floor it will *accept* is
# >=22.22.0 (react-router's engines.node), so an existing managed Node in
# that range is kept rather than replaced.
#
# PATH puts the Hermes directories ahead of Homebrew on purpose. npm
# 11.10.0-11.16.x ignores this repo's .npmrc settings, and its package.json
# excludes that band with engine-strict, so a Homebrew npm inside it cannot
# install the tree — letting it win here forces a needless managed-Node
# reinstall. Measured on the two hosts this role targets: one had npm
# 11.19.0 (clear) and the other 11.12.1 (inside the band).
- name: Run the Hermes installer
ansible.builtin.command: bash /tmp/hermes-install.sh --non-interactive --skip-setup
environment:
HOME: "{{ hermes_user_home }}"
HERMES_HOME: "{{ hermes_home }}"
PATH: "{{ hermes_path_dirs | join(':') }}:/opt/homebrew/bin:{{ ansible_env.PATH }}"
changed_when: true
notify: Restart Hermes gateway
- name: Remove the downloaded installer
ansible.builtin.file:
path: /tmp/hermes-install.sh
state: absent
- name: Confirm the Hermes launcher exists
ansible.builtin.stat:
path: "{{ hermes_bin }}"
register: hermes_launcher
# `not ansible_check_mode` matters more than it looks. Under --check the
# install block above is skipped, so on a host without Hermes the launcher is
# legitimately absent and this guard would abort the dry run — making --check
# useless on exactly the machine you most want to preview. Under a real run it
# still catches an installer that exited 0 without producing a launcher.
- name: Fail when the launcher is missing after install
ansible.builtin.fail:
msg: "Hermes installed but {{ hermes_bin }} is absent. Read the installer output above."
when:
- not ansible_check_mode
- not hermes_launcher.stat.exists
# The installer exports PATH only inside its own shell. Without this, a later
# `ssh minime hermes --version` fails with "command not found" even though the
# binary is right there. Appended, not prepended: ~/.local/bin also holds
# node/npm symlinks to the installer's private Node, and Homebrew's node should
# keep winning. The guard stops PATH growing every time a shell sources these.
- name: Put the Hermes binaries on the login PATH
ansible.builtin.lineinfile:
path: "{{ hermes_user_home }}/{{ item }}"
line: >-
[[ ":$PATH:" == *":{{ hermes_path_dirs[0] }}:"* ]] ||
export PATH="$PATH:{{ hermes_path_dirs | join(':') }}"
regexp: '^\[\[ ":\$PATH:" == \*":{{ hermes_path_dirs[0] | regex_escape }}:"\*'
create: true
state: present
owner: "{{ hermes_user }}"
mode: "0644"
loop: "{{ hermes_shell_profiles }}"
- name: Create the log directory
ansible.builtin.file:
path: "{{ hermes_log_dir }}"
state: directory
owner: "{{ hermes_user }}"
mode: "0700"
# Three things here are load-bearing, and two of them cost me a debugging session.
#
# 1. The variable names are NOT prefixed. It is API_SERVER_KEY, not
# HERMES_API_SERVER_KEY. Get this wrong and the gateway starts, launchd
# reports the job healthy with a live PID and exit status 0, and there is
# simply no API server listening — because an unrecognised variable does not
# enable anything and nothing warns you.
# 2. blockinfile, not template, so keys you add to .env by hand survive a
# playbook run. Ansible owns the marked block and nothing else in the file.
# 3. no_log, because the block contains the API key and would otherwise be
# printed in full by --diff and by -v.
- name: Write the Hermes environment block
ansible.builtin.blockinfile:
path: "{{ hermes_home }}/.env"
create: true
owner: "{{ hermes_user }}"
mode: "0600"
marker: "# {mark} ANSIBLE MANAGED BLOCK — hermes"
block: |
API_SERVER_ENABLED=true
API_SERVER_HOST={{ hermes_api_host }}
API_SERVER_PORT={{ hermes_api_port }}
API_SERVER_KEY={{ hermes_api_server_key }}
no_log: true
notify: Restart Hermes gateway
- name: Ensure the LaunchAgents directory exists
ansible.builtin.file:
path: "{{ hermes_user_home }}/Library/LaunchAgents"
state: directory
owner: "{{ hermes_user }}"
mode: "0755"
when: not hermes_needs_root
- name: Deploy the launchd job
ansible.builtin.template:
src: launchd.plist.j2
dest: "{{ hermes_plist_path }}"
owner: "{{ 'root' if hermes_needs_root else hermes_user }}"
group: "{{ 'wheel' if hermes_needs_root else 'staff' }}"
mode: "0644"
become: "{{ hermes_needs_root }}"
notify: Restart Hermes gateway
- name: Apply any pending restart before checking health
ansible.builtin.meta: flush_handlers
- name: Check whether the gateway is loaded
ansible.builtin.command: "launchctl list {{ hermes_service_label }}"
become: "{{ hermes_needs_root }}"
register: hermes_service_state
changed_when: false
failed_when: false
- name: Load the gateway when it is not already running
ansible.builtin.command: "{{ hermes_launchctl_load }}"
become: "{{ hermes_needs_root }}"
changed_when: true
when: hermes_service_state.rc != 0
# Everything from here down talks to a running service, which cannot exist in
# check mode. Gating these keeps `make check` a useful preview instead of a
# guaranteed failure.
- name: Wait for the API server to answer
when: not ansible_check_mode
ansible.builtin.uri:
url: "http://127.0.0.1:{{ hermes_api_port }}/health"
status_code: [200, 401, 403]
register: hermes_health
retries: "{{ hermes_health_retries }}"
delay: "{{ hermes_health_delay }}"
until: hermes_health.status is defined and hermes_health.status in [200, 401, 403]
# A health endpoint that answers proves the process is up. This proves the key
# is actually enforced — an agent API open to the LAN is the failure that
# matters, and it looks identical to success until you test for it.
- name: Verify the API rejects an unauthenticated request
when: not ansible_check_mode
ansible.builtin.uri:
url: "http://127.0.0.1:{{ hermes_api_port }}/v1/models"
status_code: [401, 403]
changed_when: false
- name: Report the installed version
when: not ansible_check_mode
ansible.builtin.command: "{{ hermes_bin }} --version"
register: hermes_final_version
changed_when: false
environment:
HERMES_HOME: "{{ hermes_home }}"
- name: Show what is running
when: not ansible_check_mode
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}: {{ hermes_final_version.stdout_lines[0] }}
as {{ hermes_user }}, {{ hermes_service_scope }} scope,
API on {{ hermes_api_host }}:{{ hermes_api_port }}
Detailed breakdown
- The two
set_facttasks resolve scope once. Without them, every subsequent task would carry its ownif hermes_service_scope == 'daemon'ternary, and the first one written slightly differently is a bug nobody spots. - The vault assert runs before anything slow. A first install takes minutes;
discovering a missing key afterwards wastes all of it. The failure message says
where the key comes from and what it grants, because an assert that only says
False is not Trueleaves the reader to work out which of the two conditions failed. Check the installed Hermes versionis the idempotence gate. If the launcher answers, the entire install block is skipped. This onefailed_when: falsecommand is what turns a re-run from a ten-minute reinstall into a three-second no-op.--non-interactive --skip-setupkeeps the installer from hanging the play. The installer’s setup wizard prompts on stdin. Under Ansible there is nobody to answer, and the play hangs until you kill it.- The installer task sets its own
PATH, ahead of Homebrew. This is the one place the role deliberately wins over Homebrew, and it is worth knowing why. npm 11.10.0 through 11.16.x ignores the settings in Hermes’s.npmrc, and itspackage.jsonexcludes that band withengine-strict, so a system npm inside it cannot install the dependency tree and the installer falls back to a managed-Node reinstall it did not need. This is not hypothetical on the two machines here: one carries npm 11.19.0, safely clear, and the other 11.12.1, squarely inside the band. not ansible_check_modeappears five times, and it is the difference between a useful--checkand a useless one. In check mode the install block is skipped, so on a machine without Hermes the launcher is legitimately absent — and the guard that verifies it would abort the dry run on exactly the host you most want to preview. The same applies to every task that talks to a running service. Under a real run all five still fire.- The
PATHline is guarded, and appended rather than prepended. The guard ([[ ":$PATH:" == *":...:"* ]] ||) matters because all three profiles get the line and a login shell sources more than one of them; without itPATHgrows every time you open a terminal. Appending is deliberate:~/.local/binalso holdsnode/npmsymlinks to the installer’s private Node, and Homebrew’s node should keep winning in an interactive shell. - This role ends up owning
PATHoutright, and it is worth knowing why rather than assuming. The installer will edit shell configs itself: if~/.local/binis absent from$PATHit appends a prepending line to.zshrcand.zprofile, creating.zshrcif neither exists. It never touches.zshenvunder any circumstances. That block is gated on~/.local/binbeing missing from$PATH— and because the installer task above runs with those directories already first, it never fires. Checked on the mini afterwards:.zshenv,.zprofileand.zshrceach hold exactly onePATHline, this one. Change that task’sPATHand you inherit the installer’s edits as well as your own. - The environment block is
blockinfilewithno_log, and the variable names are not prefixed. All three points are the subject of Step 8. flush_handlersbefore the health check. Handlers normally run at the end of a play, which would be after the task that waits for the service to answer. Forcing them early means the health check tests the configuration that was just written rather than the one it replaced.Verify the API rejects an unauthenticated requestis the security test. A/healthendpoint answering proves a process is up. This proves the key is enforced — and an agent API accidentally left open looks exactly like a working one until you ask it for something without credentials.
Add the code: roles/hermes/handlers/main.yml
---
# `launchctl unload` returns as soon as SIGTERM is sent, not when the process is
# gone. Reloading immediately can start the new gateway while the old one still
# holds the API port; it fails to bind, and because the gateway does not retry
# it comes up *without* its API server. launchd reports the job healthy either
# way, so the only symptom is /health refusing connections.
#
# The port test deliberately avoids a pipe. `netstat -an | grep -q` looks like
# the obvious way to write it and is silently broken under `set -o pipefail`:
# grep exits 0 on its first match and closes the pipe, netstat dies of SIGPIPE,
# and the pipeline status becomes 141. A `|| break` then fires on the very
# iteration where the port is still held, so the loop exits immediately and
# waits for nothing. Capturing the output first keeps the status honest.
#
# `netstat` rather than `lsof` because whatever still holds the port after an
# unload does not reliably have an owning process for lsof to report; netstat
# shows the socket either way. Treat the wait as empirical: it is here because
# reloading too fast produced a gateway with no listener, not because the exact
# kernel state has been pinned down.
- name: Restart Hermes gateway
ansible.builtin.shell: |
set -o pipefail
{{ hermes_launchctl_unload }} 2>/dev/null || true
for _ in $(seq 1 {{ hermes_restart_drain_retries }}); do
conns="$(netstat -an)"
case "$conns" in
*".{{ hermes_api_port }} "*) sleep 1 ;;
*) break ;;
esac
done
{{ hermes_launchctl_load }}
args:
executable: /bin/bash
# hermes_needs_root is set by the role's first task; using it here rather than
# re-deriving the ternary keeps scope resolved in exactly one place.
become: "{{ hermes_needs_root }}"
changed_when: true
Detailed breakdown
- The drain loop exists because
launchctl unloadreturns before the job is gone. It comes back as soon as SIGTERM is sent, not when the process has exited and released its port. Reload immediately and the new gateway fails to bind; because it does not retry, it comes up without its API server, andlaunchctlstill reports the job healthy. The only symptom is a refused connection on a service that looks fine. netstat, notlsof, and this is the part that is easy to get wrong. The sockets still holding the port are inTIME_WAITand have no owning process.lsofcannot see them, so a wait loop built onlsofreturns immediately, reports success, and fixes nothing.becomeis templated from the scope, because unloading a LaunchDaemon needs root and unloading a LaunchAgent must not have it — a rootlaunchctlcannot see a user’s agent domain.
Step 8: Put the API key in a vault
The one hard rule: a credential never appears in the role, the inventory, or the playbook. Hermes needs an API key for its OpenAI-compatible server, and that key carries real authority: whoever holds it can drive an agent that has a terminal on the target machine.
Create the files
printf 'a-real-passphrase-not-this-one\n' > .vault-pass
chmod 600 .vault-pass
Then generate and encrypt the key. The Makefile in the next step wraps this as
make vault-init, which is worth reading before running:
printf '%s\n' '---' 'hermes_api_server_key: "'"$(openssl rand -hex 32)"'"' \
> group_vars/all/vault.yml
ansible-vault encrypt group_vars/all/vault.yml
Encryption successful
Confirm it is genuinely encrypted rather than merely renamed:
head -1 group_vars/all/vault.yml
$ANSIBLE_VAULT;1.1;AES256
Detailed breakdown
- Commit
vault.yml, never.vault-pass. The encrypted file in git is the point: it makes the repository a complete description of the host. The passphrase travels by another channel. - The three things the role does with this key are worth listing together,
because getting any one wrong undoes the other two: it is written with
mode: "0600", the task carriesno_log: trueso--diffand-vdo not print it, and the file it lands in is under a directory the installer creates mode0700. blockinfilerather thantemplateso that keys you add to.envby hand survive a playbook run. Ansible owns the region between its markers and leaves the rest of the file alone. Atemplatetask would silently delete anything you added.- The variable names have no
HERMES_prefix. It isAPI_SERVER_KEY, notHERMES_API_SERVER_KEY. I got this wrong on the first run and the result is worth describing, because it is the most confusing failure in this whole build: the gateway started,launchctl listreported a live PID and exit status0, the logs showed a clean startup — and/healthrefused every connection, because an unrecognised environment variable enables nothing and warns about nothing. Twenty-four health-check retries later the play failed withConnection refusedagainst a service that was, by every other measure, running perfectly.
Step 9: The playbook and the Makefile
The playbook is deliberately thin. Everything worth knowing is in the role, and a playbook that accumulates logic is a role that has not been written yet.
Create the files
touch playbooks/install-hermes.yml
touch Makefile
Add the code: playbooks/install-hermes.yml
# Install Hermes Agent on every Mac in the inventory.
#
# ansible-playbook playbooks/install-hermes.yml
# ansible-playbook playbooks/install-hermes.yml --limit devbot5
# ansible-playbook playbooks/install-hermes.yml -e hermes_force_update=true
---
- name: Install Hermes Agent on macOS
hosts: macs
gather_facts: true
vars_files:
- ../group_vars/all/vault.yml
pre_tasks:
# Ansible's default become=False is set in ansible.cfg; facts must be
# gathered as the connection user so ansible_user_dir and friends describe
# the account the agent will actually run as, not root.
- name: Show what this run will do
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}: {{ hermes_service_scope }} scope as
{{ hermes_user | default(ansible_user) }}, API bound to
{{ hermes_api_host }}. A first install downloads its own Python and
Node runtime and takes several minutes.
roles:
- hermes
Detailed breakdown
hosts: macstargets the parent group, so one run does both machines and--limitnarrows it when you want one.vars_fileswith the relative../group_vars/all/vault.ymlis the explicit load Step 4 described. The path is relative to the playbook, which is why it climbs one level.gather_facts: truebecause the role asserts onansible_facts['system']and defaultshermes_userfromansible_user.
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help ping lint syntax check install install-local install-mini force-update status logs vault-init vault-edit vault-view clean
# Override on the command line: make install LIMIT=minime
LIMIT ?= macs
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
ping: ## Check Ansible can reach every Mac
ansible macs -m ping
lint: ## Lint the playbook and role with ansible-lint
ansible-lint playbooks/ roles/
syntax: ## Parse the playbook without connecting to anything
ansible-playbook playbooks/install-hermes.yml --syntax-check
check: ## Dry run against $(LIMIT), showing what would change
ansible-playbook playbooks/install-hermes.yml --limit $(LIMIT) --check --diff
install: ## Install Hermes on $(LIMIT) (default: every Mac)
ansible-playbook playbooks/install-hermes.yml --limit $(LIMIT)
install-local: ## Install Hermes on the workstation only
ansible-playbook playbooks/install-hermes.yml --limit workstations
install-mini: ## Install Hermes on the headless mini only
ansible-playbook playbooks/install-hermes.yml --limit headless
force-update: ## Re-run the installer on $(LIMIT) to upgrade an existing install
ansible-playbook playbooks/install-hermes.yml --limit $(LIMIT) -e hermes_force_update=true
status: ## Show the launchd job state on every Mac
# --become is required: a LaunchDaemon lives in the system domain and is
# invisible to an unprivileged `launchctl list`.
ansible macs -m shell --become -a 'launchctl list | grep com.homelab.hermes || echo "not loaded"'
logs: ## Tail the gateway log on $(LIMIT)
ansible $(LIMIT) -m shell -a 'tail -n 40 ~/.hermes/logs/hermes-gateway.log'
vault-init: ## Create group_vars/all/vault.yml with a generated API key
@test -f .vault-pass || { echo "Create .vault-pass first (see the README)"; exit 1; }
@test -f group_vars/all/vault.yml && { echo "vault.yml already exists — use make vault-edit"; exit 1; } || true
@printf '%s\n' \
'---' \
'# Encrypted with ansible-vault. Safe to commit; .vault-pass is not.' \
"hermes_api_server_key: \"$$(openssl rand -hex 32)\"" \
> group_vars/all/vault.yml
@ansible-vault encrypt group_vars/all/vault.yml
@echo "Created and encrypted group_vars/all/vault.yml"
vault-edit: ## Edit the encrypted vault
ansible-vault edit group_vars/all/vault.yml
vault-view: ## View the encrypted vault
ansible-vault view group_vars/all/vault.yml
clean: ## Remove the local fact cache
rm -rf .ansible/fact_cache
Detailed breakdown
.DEFAULT_GOAL := helpso a baremakeprints the targets instead of running the first one. The help text is generated by grepping the file for##comments, so a new target documents itself by existing.LIMIT ?= macsgives every target a host selector without duplicating them:make check LIMIT=minimepreviews one machine,make installdoes both.vault-initrefuses to overwrite an existing vault. Regenerating the key silently would leave every already-deployed host authenticating with a key the repository no longer knows.
Step 10: Run it on the workstation
Workstation half. If you are only doing the mini, the checks in this step still
apply to you — read on and substitute --limit minime.
Start with the machine you are sitting at, because when something is wrong you can look at it directly rather than through SSH. Three checks before the install itself: the syntax parses, the Macs answer, and the role lints clean.
cd ~/hermes-ansible
make syntax
make ping
minime | SUCCESS => {
"ping": "pong"
}
devbot5 | SUCCESS => {
"ping": "pong"
}
make lint is worth running before every commit, not just the first one:
make lint
Passed: 0 failure(s), 0 warning(s) in 6 files processed of 8 encountered. Last profile that met the validation criteria was 'production'.
production is ansible-lint’s strictest built-in profile. Reaching it is not
ceremony — it is what caught the one place this role used command where a
module belonged, and it is the cheapest review you will get.
Now preview the change:
make check LIMIT=devbot5
PLAY RECAP *********************************************************************
devbot5 : ok=11 changed=4 unreachable=0 failed=0 skipped=14 rescued=0 ignored=0
Four changes previewed, nothing failed. That clean dry run is only possible
because of the not ansible_check_mode guards from Step 7; without them this
command aborts partway with a launcher-is-missing error on any machine that does
not already have Hermes.
Then the install:
make install-local
TASK [hermes : Show what is running] *******************************************
ok: [devbot5] => {
"msg": "devbot5: Hermes Agent v0.20.5 (2026.8.19) · upstream fd760435 as mitch, agent scope, API on 127.0.0.1:8642"
}
PLAY RECAP *********************************************************************
devbot5 : ok=19 changed=2 unreachable=0 failed=0 skipped=6 rescued=0 ignored=0
agent scope, 127.0.0.1: the workstation profile, applied without anything in
the inventory saying so beyond which group the host is in.
Read the recap rather than the prose, because it says something the prose would
gloss over. skipped=6 means all three tasks in the install block skipped, which
happens only when hermes --version already exits 0. This transcript is a
machine that had Hermes converging onto a corrected configuration, not a first
install — on this host the first attempt installed Hermes and then failed on the
health check, and the run above is the one after the fix. A genuine first install
takes several minutes while the installer fetches its own Python and Node, and
its recap shows skipped=3 with the three install tasks reported changed.
The changed=4 previewed by make check a moment ago and the changed=2 here
do not line up either, for the same reason: the two commands saw the host in
different states.
Now the run that matters more than the first one:
make install-local
PLAY RECAP *********************************************************************
devbot5 : ok=18 changed=0 unreachable=0 failed=0 skipped=6 rescued=0 ignored=0
changed=0. Eighteen tasks ran, every one of them found reality already
matching what it declared, and nothing was touched. That number is the whole
argument for doing this in Ansible rather than in a shell script: the playbook is
now a description of the machine that you can re-apply at any time to find out
whether it is still true.
Step 11: Run it on the headless mini
Headless half. Skip this step entirely if you have no mini; nothing later depends on it.
The mini gets the identical command with a different --limit. Everything that
differs is already encoded in inventory/group_vars/headless.yml.
make install-mini
TASK [hermes : Ensure the LaunchAgents directory exists] ***********************
skipping: [minime]
TASK [hermes : Deploy the launchd job] *****************************************
changed: [minime]
That skip is the when: not hermes_needs_root guard from Step 7, not the
template branch from Step 6 — a Jinja {% if %} inside a template produces no
task result and leaves no trace in the output, which is why the plist has to be
inspected directly below. Then:
TASK [hermes : Show what is running] *******************************************
ok: [minime] => {
"msg": "minime: Hermes Agent v0.20.4 (2026.8.18) as serviceuser, daemon scope, API on 0.0.0.0:8643"
}
PLAY RECAP *********************************************************************
minime : ok=18 changed=5 unreachable=0 failed=0 skipped=7 rescued=0 ignored=0
That transcript shows port 8643 rather than the default 8642 because this
mini was already running a production Hermes gateway on 8642, and the validation
run was given -e hermes_api_port=8643 to avoid taking it down. On a mini with
nothing on it, the default applies and the line reads 0.0.0.0:8642. It is a
fair demonstration of why -e exists.
Confirm the two things that distinguish a daemon from an agent. First, the job really is in the system directory and owned by root:
ssh serviceuser@minime 'ls -l /Library/LaunchDaemons/com.homelab.hermes.plist'
-rw-r--r-- 1 root wheel 1446 Aug 22 23:57 /Library/LaunchDaemons/com.homelab.hermes-articletest.plist
That path carries the isolated label the validation run used, for the reason
given above. The size will not match yours exactly either: it moves with the
label and the account name, and the template shipped here renders 1403 bytes for
com.homelab.hermes under serviceuser. What matters in that line is
root wheel and -rw-r--r--.
Second, and this is the one worth checking rather than assuming, the process it started is not running as root:
ssh serviceuser@minime "ps -o user=,pid=,command= -p \$(sudo launchctl list | awk '/com.homelab.hermes/{print \$1}')"
serviceuser 11208 /Users/serviceuser/.hermes/hermes-agent/venv/bin/pyt
launchd loaded the job as root, read UserName, and dropped to serviceuser
before exec. That is the whole reason a LaunchDaemon is acceptable for an
autonomous agent: it survives reboots without a login session, and it still does
not hand the agent root.
The sudo in that last command is doing real work. A LaunchDaemon lives in
the system domain, so a plain launchctl list as an unprivileged user does not
list it at all — which is why the role’s own status check carries become.
And the second run, as on the workstation:
make install-mini
PLAY RECAP *********************************************************************
minime : ok=17 changed=0 unreachable=0 failed=0 skipped=7 rescued=0 ignored=0
Troubleshooting
'hermes_installer_url' is undefined. Your group_vars/ is in the wrong
place. Ansible auto-loads it only from beside the inventory or beside the
playbook; a copy at the project root next to neither is ignored without warning.
Move it to inventory/group_vars/. To settle a precedence question, ask for the
one variable you care about:
ansible devbot5 -m debug -a 'var=hermes_api_host'
Reach for ansible-inventory --host devbot5 only if you know what it does with
secrets. Run from the repository root it decrypts and prints the vault,
because an ad-hoc command has no playbook and therefore treats the current
directory as the basedir, which makes the root group_vars/all/vault.yml
auto-loadable after all. The no_log on the role’s template task exists to keep
that key out of terminals; do not undo it with a debugging command.
The play fails on Wait for the API server to answer, but launchctl list
shows the job running with exit status 0. The service is up and its API server
is not, which almost always means the environment file did not enable it. Check
the names: they are API_SERVER_ENABLED / API_SERVER_HOST / API_SERVER_PORT
/ API_SERVER_KEY, with no HERMES_ prefix. An unrecognised variable is
ignored silently. Read ~/.hermes/logs/hermes-gateway.err — a clean startup log
with no mention of an API server is the confirmation.
make check fails on a machine that does not have Hermes yet. Expected
unless the not ansible_check_mode guards from Step 7 are in place: check mode
skips the install, so the launcher is genuinely missing and the verification
task that follows is right to complain.
zsh: command not found: hermes over SSH, but it works when you log in.
The PATH went into .zprofile and .zshrc but not .zshenv. Non-interactive
shells source .zshenv alone, and that is what ssh host cmd gets. Note this
is a problem for you, not for the playbook: Ansible runs its commands under
/bin/sh and reads no zsh startup file at all, which is why the role always
invokes Hermes by absolute path.
The mini’s job will not load, or loads and immediately exits. Check that the
plist is owned root:wheel; launchd refuses to load a daemon from a
user-writable file. If it loads and dies, the usual cause is a missing
EnvironmentVariables block — launchd gives a process almost no environment,
so an absolute PATH in the plist is required rather than inherited.
sudo: a password is required during the LaunchDaemon tasks. The connection
account needs passwordless sudo on the target. Verify with
ssh <user>@<host> sudo -n true before blaming Ansible.
Recap
You built one Ansible role that installs Hermes Agent on two Macs that need
genuinely different treatment, and kept the difference down to two small
group_vars files and one conditional in a plist template. The workstation gets
a LaunchAgent bound to loopback; the headless mini gets a LaunchDaemon that
starts at boot and drops from root to a service account. Neither machine has a
forked copy of anything.
The result that matters is changed=0 on the second run. A shell script tells
you what happened once. A role that reports no changes is telling you the machine
still matches its description — which is what makes a wiped mini recoverable from
the repository instead of from memory.
Three things in here cost real debugging time and are worth carrying to your next
macOS Ansible role. group_vars/ beside neither the inventory nor the playbook
is ignored in silence. launchctl will report a job perfectly healthy while the
service inside it is missing its listener, so health-check what the service does
rather than whether the process exists. And --check cannot dry-run a first
install unless you gate the verification tasks on ansible_check_mode.
Where to go next:
- Add the pieces this role deliberately left out. A Traefik route and an Open WebUI frontend give the mini a chat UI on the LAN; the Slack gateway makes the agent reachable from a phone. The Slack half has a trap worth knowing before you start. Socket Mode needs two tokens, and with only one the gateway comes up looking healthy and never connects.
- Point it at a local model. The role writes a config that assumes a hosted
provider. Swapping in a local OpenAI-compatible endpoint is three keys
(
model.provider,model.base_url,model.default) and turns the mini into a self-contained agent host. - Manage the account too. This role assumes the service account already
exists. A
serviceuserrole that creates it, installs the SSH key, and sets the power settings that keep a mini awake makes the machine reproducible from bare macOS rather than from “a Mac someone already set up.”