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 onminime — a headless Mac mini
Connectionlocal, no SSH at allssh
Runs asyour login accounta dedicated service account
launchd jobLaunchAgent in your homeLaunchDaemon in /Library
API bound to127.0.0.10.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 workstations group, delete the headless group and its host from the inventory, and skip Step 11. You can drop inventory/group_vars/headless.yml too, though leaving it costs nothing.
  • Headless mini only. Keep the headless group, delete workstations and 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, devbot5 and minime, 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 sudo for the account you connect as — only if you are doing the mini half. ssh <user>@<host> sudo -n true must 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 git is 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_HOME rather 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 to brew install, so they land in /opt/homebrew like 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-pass is 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.
  • *.env catches 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 = False is deliberate, and it is the security posture. Most macOS Ansible examples set become = True globally, 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_file points at the file .gitignore just excluded, so ansible-playbook and ansible-vault both find the passphrase without a prompt. On a shared machine prefer ansible-vault --vault-id, or a script that reads from the Keychain, over a file on disk.
  • pipelining = True cuts the number of SSH round trips per task. It is safe here because become is off by default; pipelining conflicts with requiretty sudo configurations, which is the usual reason people disable it.
  • fact_caching makes 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. macs is what the playbook targets, so the role attaches once. workstations and headless exist 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: local on the workstation. No sshd, no key, no loopback round trip.
  • ansible_python_interpreter: /usr/bin/python3 on both, and the reason is determinism rather than safety. Left unset, Ansible discovers whichever python3 is first on PATH, 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/python3 is the xcode-select shim, 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_url is 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_port is 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/, not group_vars/. This one cost me a failed run. Ansible auto-loads group_vars/ only when it sits beside the inventory or beside the playbook. A group_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 is inventory/hosts.yml, its group_vars belongs at inventory/group_vars/.
  • The root group_vars/all/vault.yml in Step 8 is the deliberate exception. The playbook pulls it in with an explicit vars_files, because it is not auto-loaded for a playbook — the basedir there is playbooks/. Be precise about the scope of that claim: an ad-hoc ansible or ansible-inventory command 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_scope is read in one place and then never again. The two set_fact tasks at the top of the role turn it into hermes_plist_path and hermes_needs_root, and everything downstream works from those: the handler’s become, 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 to host_vars, which loses to -e on the command line. That ordering is why the role can ship a safe 127.0.0.1 default that headless.yml overrides 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_user defaults to ansible_user. The agent runs as whoever Ansible connected as: mitch on the workstation, serviceuser on the mini. That is almost always what you want, and it is overridable when it is not.
  • hermes_shell_profiles lists three files, and .zshenv is the one that earns its place. It is the only file a non-interactive shell sources, which is what ssh minime hermes --version gets, 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_dir sits under HERMES_HOME rather than /tmp. The gateway logs prompts and tool output, and /tmp is world-readable.
  • hermes_force_update exists 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, and launchd ignores 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.
  • EnvironmentVariables has to be there. launchd starts processes with a nearly empty environment: no PATH, no HOME, nothing from any shell profile. The PATH wired into .zshenv in 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.
  • KeepAlive pairs with --external-supervisor. That flag makes an in-chat restart exit non-zero on purpose, and KeepAlive is what brings the gateway back afterwards. Together they mean “restart” works from inside the agent.
  • RunAtLoad starts 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_fact tasks resolve scope once. Without them, every subsequent task would carry its own if 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 True leaves the reader to work out which of the two conditions failed.
  • Check the installed Hermes version is the idempotence gate. If the launcher answers, the entire install block is skipped. This one failed_when: false command is what turns a re-run from a ten-minute reinstall into a three-second no-op.
  • --non-interactive --skip-setup keeps 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 its package.json excludes that band with engine-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_mode appears five times, and it is the difference between a useful --check and 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 PATH line 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 it PATH grows every time you open a terminal. Appending is deliberate: ~/.local/bin also holds node/npm symlinks to the installer’s private Node, and Homebrew’s node should keep winning in an interactive shell.
  • This role ends up owning PATH outright, and it is worth knowing why rather than assuming. The installer will edit shell configs itself: if ~/.local/bin is absent from $PATH it appends a prepending line to .zshrc and .zprofile, creating .zshrc if neither exists. It never touches .zshenv under any circumstances. That block is gated on ~/.local/bin being missing from $PATH — and because the installer task above runs with those directories already first, it never fires. Checked on the mini afterwards: .zshenv, .zprofile and .zshrc each hold exactly one PATH line, this one. Change that task’s PATH and you inherit the installer’s edits as well as your own.
  • The environment block is blockinfile with no_log, and the variable names are not prefixed. All three points are the subject of Step 8.
  • flush_handlers before 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 request is the security test. A /health endpoint 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 unload returns 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, and launchctl still reports the job healthy. The only symptom is a refused connection on a service that looks fine.
  • netstat, not lsof, and this is the part that is easy to get wrong. The sockets still holding the port are in TIME_WAIT and have no owning process. lsof cannot see them, so a wait loop built on lsof returns immediately, reports success, and fixes nothing.
  • become is templated from the scope, because unloading a LaunchDaemon needs root and unloading a LaunchAgent must not have it — a root launchctl cannot 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 carries no_log: true so --diff and -v do not print it, and the file it lands in is under a directory the installer creates mode 0700.
  • blockinfile rather than template so that keys you add to .env by hand survive a playbook run. Ansible owns the region between its markers and leaves the rest of the file alone. A template task would silently delete anything you added.
  • The variable names have no HERMES_ prefix. It is API_SERVER_KEY, not HERMES_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 list reported a live PID and exit status 0, the logs showed a clean startup — and /health refused every connection, because an unrecognised environment variable enables nothing and warns about nothing. Twenty-four health-check retries later the play failed with Connection refused against 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: macs targets the parent group, so one run does both machines and --limit narrows it when you want one.
  • vars_files with the relative ../group_vars/all/vault.yml is the explicit load Step 4 described. The path is relative to the playbook, which is why it climbs one level.
  • gather_facts: true because the role asserts on ansible_facts['system'] and defaults hermes_user from ansible_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 := help so a bare make prints 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 ?= macs gives every target a host selector without duplicating them: make check LIMIT=minime previews one machine, make install does both.
  • vault-init refuses 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 serviceuser role 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.”