Hermes Agent is Nous Research’s open-source (MIT) agent runtime: one agent with persistent memory that you can reach from a terminal or from a chat platform, backed by whichever model provider you point it at. Unlike an agent library, it ships as an installed program with its own config directory, a messaging gateway, and a command approval layer, so most of the work in getting started is deciding what it is allowed to do rather than writing code.

This tutorial installs Hermes on a MacBook Pro, gets one verified conversation working in the terminal, sets the safety boundaries before anything is exposed, and then connects a single chat channel: Slack, over Socket Mode, so no inbound port or public URL is involved. Along the way you build a small preflight checker that validates your Slack credentials and OAuth scopes before you start the gateway, because a missing scope is the most common reason a correctly installed bot sits silent in a channel.

Versions used throughout: Hermes Agent v0.20.1 (release 2026.8.13) on macOS 26. Hermes moves quickly; check hermes --version against what you see here and re-read the Slack setup page if a flag has changed.

Prerequisites

  • macOS 12 or later. Hermes supports macOS, Linux, WSL2, Termux, and native Windows; this article covers macOS only.
  • Git. On non-Windows platforms it is the installer’s only hard prerequisite. Check with git --version. Everything else (Python 3.11, Node.js 22, ripgrep, ffmpeg, and uv) is installed for you by the installer.
  • A model provider. Either a Nous Portal account (a free tier exists; the paid tiers add monthly credits) or an API key for a provider you already use — Anthropic, OpenAI, OpenRouter, and roughly forty others are supported. Hermes requires a model with at least 64K context and refuses to start below it, so check the context window before you pick, especially with a local server whose default window is often much smaller than the model’s real one.
  • A Slack workspace where you can create and install an app. Many corporate workspaces require admin approval before an app can be installed. Confirm you have that before Step 6, or use a free personal workspace for the walkthrough.
  • uv 0.5 or newer for the preflight project in Step 9. The Hermes installer brings its own copy for internal use; install a user-level one from docs.astral.sh/uv if uv --version does not resolve.

Step 1: Read the installer before you run it

The documented install path is curl … | bash. Piping a remote script into a shell means executing whatever that URL serves at the moment you run it, so fetch it first and read it.

Create the file

mkdir -p ~/hermes-install
cd ~/hermes-install
curl -fsSL https://hermes-agent.nousresearch.com/install.sh -o install.sh

Inspect it

# How big is it, and what does it fetch?
wc -l install.sh
grep -nE 'curl|wget|sudo|https?://' install.sh | head -40

At well over 3,000 lines this is not a script you will read end to end, so read it for three answers: where it installs to, whether it needs sudo, and which hosts it downloads from. For v0.20.1 those are ~/.hermes/, no on macOS, and nousresearch.com plus the upstream toolchain hosts: nodejs.org, pypi.org, astral.sh and docs.astral.sh for uv, github.com and raw.githubusercontent.com for the clone. Two more show up that are easy to misread. duckduckgo.com is only a reachability probe, and npmmirror.com is a fallback Electron mirror used solely by the optional desktop-app build. ripgrep and ffmpeg are not downloaded from anywhere; they come from your package manager.

Expect the line count to differ from whatever you read here. The installer is served from main rather than a pinned release, so it grows between readings; what matters is that the three answers stay the same.

The sudo answer needs one qualification, because the file mentions sudo dozens of times and the first of them show up in that grep. Every one sits on a Linux package-manager path (apt-get, dnf, pacman), installing Git, the optional ripgrep and ffmpeg, build tools, and Playwright’s system libraries. On macOS those same code paths shell out to brew install, which never asks for root, and the per-user install writes only under your home directory.

Then run the copy you just read:

bash install.sh

Detailed breakdown

  • Fetching to a file first turns an opaque one-liner into an artifact you can read, diff against a later version, and re-run. It costs one extra command.
  • grep for sudo is the single highest-value line in that inspection. The per-user install path needs no root at all, so an unexpected sudo would be worth understanding before it runs.
  • Install layout matters later. A per-user install puts the code in ~/.hermes/hermes-agent/, the launcher at ~/.local/bin/hermes, and all data in ~/.hermes/. A root install (sudo … | sudo bash) uses /usr/local/lib/hermes-agent/ instead. Stick with the per-user install on a laptop; the system layout exists for shared machines.
  • --skip-browser is available (bash install.sh --skip-browser) if you do not want Playwright and Chromium downloaded. Browser automation stops working; everything in this article still does. If you leave it on, the install prints Playwright does not support automatic dependency installation on macos and says browser tools will not work, then downloads Chromium (about 162 MB) anyway. The message is aimed at Linux distributions that need system libraries installed first; on macOS the download is the whole job.
  • The installer clones main, not a release tag, so two people running it a week apart get different code. --commit SHA pins the checkout if you need a reproducible install.
  • The installer creates your config before you configure anything. By the time it finishes, ~/.hermes/.env and ~/.hermes/config.yaml both exist, fully populated with commented defaults. Steps 5 and 8 edit those files rather than creating them, which matters more than it sounds — see the warning in Step 5.

Step 2: Verify the install

Reload your shell so the launcher is on PATH, then let Hermes check its own dependencies.

source ~/.zshrc
hermes --version
hermes doctor

hermes --version prints the package version and the dated release together, along with the install directory and interpreter:

Hermes Agent v0.20.1 (2026.8.13)
Install directory: /Users/you/.hermes/hermes-agent
Python: 3.11.15
OpenAI SDK: 2.24.0

hermes doctor is the diagnostic to reach for whenever something is wrong later. It walks about fifteen sections (Python environment, SSL certificates, required packages, configuration files, auth providers, directory structure, external tools, tool availability, and roughly thirty parallel API connectivity checks), then closes with a numbered count of issues it found. Anything it can repair itself, it will: hermes doctor --fix is the follow-up worth knowing about.

Read it for absences rather than presences. A next to an optional integration you do not use is noise; a under Configuration Files or Command Installation is the thing you came for.

If hermes: command not found survives the source, the launcher is not on your PATH:

echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

Step 3: Choose a model provider

Hermes has no built-in model. Nothing works until a provider is configured.

The fastest path is the Nous Portal, which covers models plus the hosted tool gateway (web search, image generation, TTS, cloud browser) under one login instead of separate keys:

hermes setup --portal

That opens an OAuth login, sets Nous as the provider, and enables the tool gateway. To use a provider you already pay for instead, walk the interactive picker:

hermes model

Either way, confirm where the value landed. Hermes splits secrets from settings: tokens go to ~/.hermes/.env, everything else to ~/.hermes/config.yaml. The CLI routes each value to the correct file for you.

hermes config get model

Two things worth knowing before you move on. A model under 64K context is rejected outright. Hermes prints Context length is only N tokens — this is likely too low for agent use with tools with a fix hint for your provider, and then startup fails with:

Model <name> has a context window of 8,000 tokens, which is below the minimum
64,000 required by Hermes Agent.

This bites hardest with a local server, because the limit reported is the server’s configured window, not the model’s true one. An Ollama model with a 262K window will still be refused if ollama serve is running at its 8K default; the fix is OLLAMA_CONTEXT_LENGTH=64000 ollama serve, or model.context_length in config.yaml set to the real window. And hermes setup on a fresh install offers a Blank Slate mode that enables only a provider, file operations, and the terminal toolset, writing an explicit toolset allowlist so nothing else loads even after an update. If you want to grant capabilities one at a time rather than turn them off later, that is the mode to pick.

Step 4: Have one real conversation

Do not configure anything else until a plain chat works end to end.

hermes          # classic CLI
hermes --tui    # newer TUI, same sessions and config

Ask something that exercises a tool and that you can verify by eye:

Check my current directory and tell me what looks like the main project file.

You are looking for four things: the banner names the provider and model you chose, the reply arrives without an error, the agent actually invokes a tool rather than guessing, and a follow-up turn keeps the context. Then confirm sessions persist, which matters as soon as more than one setup is in play:

hermes --continue

There is also a one-shot mode that sends a single prompt, prints the reply, and exits. It is the quickest way to prove the provider works without entering the interactive UI, and the only form that works over SSH or in a script:

hermes -z "What is 17 times 23? Answer with just the number."
391

If any of that fails, fix it now. A broken chat does not get better once a gateway, a second machine, and a Slack app are stacked on top of it.

Step 5: Set the safety floor before exposing anything

Connecting a chat platform turns “an agent I drive from my own terminal” into “an agent that acts on messages.” Set the boundaries first, while the only way in is still your keyboard.

Hermes layers several controls. Two need no configuration: a hardline blocklist that refuses filesystem wipes, fork bombs, and raw block-device writes before --yolo or approvals.mode: off are even consulted, and a default-deny gateway allowlist, which means an unconfigured gateway rejects every user. The rest you choose.

One exception to know now, because it inverts what the next steps are for: the hardline floor and the deny rules below are checked only for backends that can reach the host. An isolated container backend returns “approved” before either one runs, on the reasoning that the container is the boundary.

Edit the file

~/.hermes/config.yaml was created by the installer back in Step 1, not by the provider setup in Step 3, and it is not a stub: it ships around 1,900 lines of settings and commented defaults. Back it up before editing:

cp ~/.hermes/config.yaml ~/.hermes/config.yaml.bak
${EDITOR:-nano} ~/.hermes/config.yaml

Do not paste the block below at the end of the file. Two of its three top-level keys are already in there, and YAML has no notion of merging a repeated key — the loader keeps the last one and discards the first, without an error. Append a second terminal: and you silently drop every default under the original, including docker_mount_cwd_to_workspace: false, which the shipped config labels a security default. You would be disabling a safety setting in the middle of the step whose entire purpose is turning safety settings on.

So treat this as three separate edits:

  • terminal: already exists (search for ^terminal:) with backend: "local" already set. Confirm it and change nothing.
  • group_sessions_per_user: true already exists at the top level and is already true. Confirm it and change nothing.
  • approvals: does not exist. This is the one block you actually add, and it can go anywhere at the top level.

You can confirm the two that already exist without opening the file at all:

hermes config get terminal.backend
hermes config get group_sessions_per_user
local
true

If either one ever needs changing, hermes config set terminal.backend docker edits the existing key in place. That is the habit worth forming for any single-value change: the CLI cannot produce the duplicate key that hand-editing can.

Add the configuration: ~/.hermes/config.yaml

The terminal: and group_sessions_per_user blocks below are shown so you know what to look for and why they matter. Only approvals: is new.

approvals:
  # smart | manual | off. "manual" prompts on every dangerous command;
  # "smart" lets an auxiliary model auto-approve provably low-risk ones.
  mode: manual
  # Seconds to wait for an approval reply. Times out closed (denied).
  timeout: 300
  # What a scheduled job does when it hits a dangerous command with no
  # human present. "deny" makes the agent find another route.
  cron_mode: deny
  # Unconditional blocks, checked before --yolo and before mode: off.
  deny:
    # Canary rule — harmless if the guard fails, unmistakable when it works.
    # Remove it once Step 11 confirms the block fires.
    - "*whoami*"
    - "git push --force*"
    - "dd if=* of=/dev/*"

# ALREADY PRESENT — find the existing block, do not add a second one.
terminal:
  # Commands run on this host as your user. See the breakdown before
  # leaving this on a machine you care about.
  backend: local

# ALREADY PRESENT and already true — confirm, do not re-add.
# Each person in a shared channel gets their own session and history.
group_sessions_per_user: true

Detailed breakdown

  • approvals.mode: manual is the deliberate choice for a first setup. The default, smart, uses an auxiliary model to auto-approve commands it judges low-risk. That is a reasonable default once you trust the setup, but for the first hours you want to see every dangerous command Hermes flags, which is also the fastest way to learn what it considers dangerous.
  • timeout: 300 fails closed. No reply within five minutes denies the command rather than running it. This matters most on a phone, where an approval button can easily go unnoticed.
  • cron_mode: deny governs scheduled jobs, which by definition run with nobody watching. approve exists and auto-approves everything in that context; leave it at deny.
  • approvals.deny is the user-editable counterpart to the hardline blocklist. Patterns are case-insensitive fnmatch globs matched against the whole command, checked before --yolo or mode: off are consulted, and matched against normalized command text so quoting tricks like git pu""sh do not slip past. Always quote the patterns: a bare leading * is a YAML alias and will not parse.
  • The *whoami* canary earns its place. You need to know the guard works, and the honest way to know is to watch it block something. Choosing a read-only command means that if the rule somehow fails to match, all that happens is whoami prints your username. Verifying a guard with a destructive command gets you a real outage on the day the guard is broken. Delete it once Step 11 confirms the block, and keep the two real rules.
  • terminal.backend: local is the weak point of this configuration, and it is deliberate. It is also the shipped default, so this step is confirming it rather than changing it. Commands run on your Mac as you, with approval prompts as the only thing standing between a wrong tool call and your home directory. The stronger posture is backend: docker, which runs commands in a hardened container (all capabilities dropped, no-new-privileges, a PID limit, a size-limited tmpfs) and treats the container as the boundary. Note the tradeoff carefully: isolated backends skip the approval and deny-rule stack entirely, because nothing they run can reach the host. The exception is a Docker sandbox with host paths bind-mounted in — those commands can reach real files, so they go back through the normal approval flow. So local plus approvals and docker are two coherent postures, not a ladder. Keep local while you are learning what the agent does, and move to docker before this agent runs unattended.
  • group_sessions_per_user: true keeps two people talking to the bot in the same channel from sharing one conversation, one context window, and one reset. It ships as true; set it to false only when a shared thread is the point.

Changes take effect immediately. The config cache is keyed on file mtime, so there is no session to restart.

Step 6: Create the Slack app from a generated manifest

Slack apps need a set of OAuth scopes, event subscriptions, and slash commands that must match what Hermes expects. Hermes generates the whole manifest, which is faster and considerably less error-prone than clicking through the settings pages.

hermes slack manifest --agent-view --write

This writes ~/.hermes/slack-manifest.json and prints paste-in instructions. The --agent-view flag targets Slack’s Agent messaging experience, which new apps are required to use; Slack’s older Assistant view is only for apps that already exist and have not migrated.

Then, in a browser:

  1. Go to https://api.slack.com/apps and click Create New App.
  2. Choose From an app manifest.
  3. Pick your workspace, paste the contents of ~/.hermes/slack-manifest.json, review, and click NextCreate.

The manifest declares every scope in the table below, subscribes to the nine bot events Hermes needs, enables Socket Mode, and registers 50 Hermes commands (/help, /stop, /model, /btw, and the rest) as native Slack slash commands. Fifty is not a coincidence: it is Slack’s per-app ceiling, so a hand-built app that already has slash commands of its own will not have room for the full set.

One of those commands is /whoami, which is worth noticing now because Step 5’s canary deny rule is also *whoami*. They do not interact: the deny rule matches shell commands the agent tries to run, while /whoami is a Slack command asking Hermes which user it thinks you are. Step 11 exercises the deny rule by asking the agent in prose, not by typing the slash command.

For reference, these are the seventeen bot token scopes --agent-view requests:

ScopePurpose
chat:writeSend messages as the bot
app_mentions:readDetect @mentions in channels
channels:historyRead messages in public channels the bot is in
channels:readList and inspect public channels
commandsRegister and receive the /btw, /stop, /model slash commands
groups:historyRead messages in private channels the bot is invited to
groups:readList and inspect private channels
im:historyRead direct message history
im:readView basic DM info
im:writeOpen and manage DMs
mpim:historyRead group DM history
mpim:readView basic group DM info
reactions:readReceive the reaction events the adapter acts on
users:readLook up user information
files:readRead and download attachments, including voice notes
files:writeUpload files
assistant:writeSet the “is thinking…” status line

channels:history and groups:history are the two whose absence produces the confusing failure: the bot answers DMs perfectly and stays silent in every channel. assistant:write is not needed for messages to flow — without it Slack substitutes its own rotating placeholder text — but --agent-view adds it to the manifest either way, so you get it by following this step.

If you would rather build the app by hand, the Slack setup page documents the manual path through OAuth scopes, Socket Mode, event subscriptions, and the App Home settings. The manifest does all of it in one paste.

Step 7: Collect two tokens, one channel, and your member ID

Socket Mode uses a WebSocket connection opened outbound from your Mac, so Slack never needs to reach your machine. That takes two different tokens.

App-level token (xapp-), which authorizes the socket itself. This is the one credential the manifest cannot give you — Slack’s manifest schema has no field for app-level tokens, and connections:write is an app-level scope rather than a bot scope, so it never appears in the oauth_config.scopes block you pasted. Socket Mode is switched on by the manifest and left unusable until you create this by hand:

  1. In the app’s sidebar, go to Settings → Basic Information → App-Level Tokens and click Generate Token and Scopes. Name it hermes-socket, add the connections:write scope, and click Generate. Expect the list to be empty before you do this; there is nothing for the manifest to have created.
  2. Copy the value. It starts with xapp-.

Bot token (xoxb-), which authorizes API calls:

  1. Go to Settings → Install App and click Install to Workspace.
  2. Review the permissions and click Allow.
  3. Copy the Bot User OAuth Token. It starts with xoxb-.

Your Slack member ID, which is what the allowlist matches (not your username or display name): click your avatar in Slack → View full profile → the menu → Copy member ID. It looks like U01ABC2DEF3.

This one is desktop-only. Slack exposes “Copy member ID” in the web and desktop clients but not in the iOS or Android apps, and there is no mobile equivalent — the documented trick of building a profile link (https://yourworkspace.slack.com/team/U01ABC2DEF3) needs the ID you are trying to find. If you are setting this up from a phone, let Hermes tell you instead. Leave SLACK_ALLOWED_USERS unset for now, start the gateway (Step 10), and DM the bot. Because no allowlist is configured yet, it answers an unrecognized sender with a pairing code:

Hi~ I don't recognize you yet!

Here's your pairing code: `A1B2C3D4`

Ask the bot owner to run:
`hermes pairing approve slack A1B2C3D4`

Run that command on the Mac and you are authorized without ever knowing your member ID. If you would rather have the ID itself — to put it in SLACK_ALLOWED_USERS and keep the allowlist as the single source of truth — hermes pairing list prints it in a User ID column for every pending and approved user.

Note the ordering that makes this work: unrecognized DMs are answered with a pairing code only while no allowlist is configured. Once SLACK_ALLOWED_USERS is set, Hermes switches to ignoring unknown senders silently rather than handing pairing codes to strangers, so collect the ID before you fill in the allowlist, not after.

If you change scopes or event subscriptions later, you must reinstall the app for the change to take effect. Slack shows a banner on the Install App page when a reinstall is pending, and it is easy to miss.

Step 8: Write the credentials into ~/.hermes/.env

The two tokens and the member ID from Step 7 now need somewhere to live. Hermes keeps secrets separate from settings: anything token-shaped goes in ~/.hermes/.env, and everything else in config.yaml. Nothing here is Slack-specific machinery; it is the same file every other provider and channel reads from, which is why it is worth being careful about how you edit it.

Edit the file

Like config.yaml, this file already exists — the installer wrote it, about 24 KB of commented provider and integration settings, already at mode 600. Check that before you paste a live token into it, then open it:

ls -l ~/.hermes/.env
${EDITOR:-nano} ~/.hermes/.env

It already contains a commented Slack block. Search for SLACK_BOT_TOKEN and edit those lines in place rather than appending a new block at the end. A duplicate SLACK_BOT_TOKEN= is not an error here the way a duplicate YAML key is, but you end up with two lines that disagree and only one of them in effect, which is a miserable thing to debug six months later.

Add the configuration: ~/.hermes/.env

# Slack — Socket Mode requires both tokens.
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
SLACK_APP_TOKEN=xapp-your-app-token-here

# Comma-separated Slack member IDs. Anyone not listed is denied.
SLACK_ALLOWED_USERS=U01ABC2DEF3

# Optional: where scheduled jobs post when no conversation is in progress.
# Neither key is in the shipped template; add these two at the end of the
# Slack section.
SLACK_HOME_CHANNEL=C01234567890
SLACK_HOME_CHANNEL_NAME=general

Detailed breakdown

  • The file arrives at mode 600 already, so there is nothing to fix — but confirm it rather than assume it, because this file holds every secret Hermes knows, not just Slack’s. If it ever reads -rw-r--r--, chmod 600 it before the next token goes in.
  • Two tokens, two jobs. SLACK_APP_TOKEN opens the WebSocket; SLACK_BOT_TOKEN authorizes each API call the bot makes. Supplying one and not the other produces a gateway that either cannot connect or connects and cannot speak.
  • SLACK_ALLOWED_USERS is the access control for this bot. Authorization is checked in order: per-platform allow-all flag, DM-pairing approvals, platform allowlist, global GATEWAY_ALLOWED_USERS, global allow-all, then deny. With none of them set every user is denied and the gateway logs a warning at startup. Resist GATEWAY_ALLOW_ALL_USERS=true: it hands shell access on your laptop to anyone who can find the bot.
  • Member IDs, not names. @mitch will not match. IDs start with U (or W on Enterprise Grid).
  • SLACK_HOME_CHANNEL is optional and only matters once scheduled jobs are in play, but setting it now avoids a puzzling “where did that message go” later. It takes a channel ID, not a name.
  • Pairing is the alternative to hand-collecting IDs. An unknown user who DMs the bot receives an 8-character code, and you approve it with hermes pairing approve slack <code>. Codes expire after an hour and are rate limited. For a single-user setup the allowlist is simpler.

Step 9: Preflight the credentials before starting the gateway

A wrong token and a missing scope fail the same way from the outside: the bot does nothing. The difference is visible in one API call, so make it before starting the gateway rather than after.

This step builds a small uv project that reads ~/.hermes/.env, checks the token shapes, calls Slack’s auth.test, and diffs the granted scopes against the list Hermes needs.

Scaffold the project and its .gitignore

Create the workspace and the .gitignore before anything else, so no generated file can reach a commit. This project sits next to secrets; the .gitignore is doing real work.

Create the files

mkdir -p ~/hermes-slack-preflight
cd ~/hermes-slack-preflight
touch .gitignore

Add the code: .gitignore

# Secrets — never commit a token, even a revoked one
.env
*.env
slack-manifest.json

# Python
__pycache__/
*.py[cod]
.venv/

# Tooling caches
.pytest_cache/
.ruff_cache/
.mypy_cache/

# OS / editor noise
.DS_Store
*.log

Detailed breakdown

  • .env and *.env first. The tool reads ~/.hermes/.env by default, but the obvious next move is copying one into the project to test against. Ignoring it up front means that copy cannot be committed.
  • slack-manifest.json is not secret, but the generated file can pick up an app description from a local file and there is no reason for it to live here.
  • .venv/ and the caches are regenerated from the lockfile on any machine, so committing them adds size and merge conflicts and nothing else.

Initialize with uv

Create the file

uv init --name hermes-slack-preflight --python 3.11
rm -f main.py
uv add httpx
uv add --dev pytest

Add the code: pyproject.toml (generated, shown for reference)

[project]
name = "hermes-slack-preflight"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
    "httpx>=0.28.1",
]

[dependency-groups]
dev = [
    "pytest>=9.1.1",
]

Detailed breakdown

  • The description is uv init’s placeholder. Replace it with something like “Validate Slack credentials and scopes before starting the Hermes gateway”; nothing here depends on it, but the file is the project’s front door.
  • The two version floors are whatever was current when you ran uv add, so yours will differ from these. uv pins the installed version as the minimum.
  • requires-python = ">=3.11" matches the Python the Hermes installer provisions, so the checker runs on the same major version as the agent it is checking.
  • httpx handles the two Slack API calls. It exposes response headers cleanly, which matters because the granted scope list arrives in a header rather than the JSON body.
  • pytest in [dependency-groups].dev is where uv add --dev puts it. The tests cover the parsing and comparison logic only, so they need no tokens and no network.

Add the checker

Create the file

mkdir -p src
touch src/preflight.py

Add the code: src/preflight.py

"""Validate Slack credentials for a Hermes Agent gateway before starting it.

Reads ~/.hermes/.env, checks token shapes and the allowlist, then asks Slack
which OAuth scopes the bot token actually carries and compares that against
what the Hermes Slack adapter needs. Token values are never printed.
"""

from __future__ import annotations

import os
import re
import sys
from pathlib import Path

import httpx

DEFAULT_ENV_PATH = Path.home() / ".hermes" / ".env"

# Bot token scopes the Hermes Slack adapter requires. channels:history and
# groups:history are the two whose absence looks like "works in DMs, silent
# in channels" rather than like an error.
REQUIRED_SCOPES = frozenset(
    {
        "chat:write",
        "app_mentions:read",
        "channels:history",
        "channels:read",
        "commands",
        "groups:history",
        "im:history",
        "im:read",
        "im:write",
        "mpim:history",
        "mpim:read",
        "reactions:read",
        "users:read",
        "files:read",
        "files:write",
    }
)

# Optional, but its absence has a visible symptom worth naming.
OPTIONAL_SCOPES = {
    "assistant:write": "custom 'is thinking…' status line",
    "groups:read": "listing private channels",
}

MEMBER_ID = re.compile(r"^[UW][A-Z0-9]{6,}$")


def parse_env(path: Path) -> dict[str, str]:
    """Parse a KEY=VALUE .env file, ignoring blanks and comments."""
    values: dict[str, str] = {}
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        values[key.strip()] = value.strip().strip("'\"")
    return values


def mask(token: str) -> str:
    """Render a token as a prefix and length, never its secret body."""
    prefix = token.split("-", 1)[0] if "-" in token else token[:4]
    return f"{prefix}-… ({len(token)} chars)"


def check_shapes(env: dict[str, str]) -> list[str]:
    """Return a problem list for token and allowlist shapes."""
    problems: list[str] = []

    bot = env.get("SLACK_BOT_TOKEN", "")
    if not bot:
        problems.append("SLACK_BOT_TOKEN is missing")
    elif not bot.startswith("xoxb-"):
        problems.append("SLACK_BOT_TOKEN should start with 'xoxb-' (bot token)")

    app = env.get("SLACK_APP_TOKEN", "")
    if not app:
        problems.append("SLACK_APP_TOKEN is missing")
    elif not app.startswith("xapp-"):
        problems.append(
            "SLACK_APP_TOKEN should start with 'xapp-' (app-level token)"
        )

    allowed = [u.strip() for u in env.get("SLACK_ALLOWED_USERS", "").split(",")]
    allowed = [u for u in allowed if u]
    if not allowed:
        problems.append(
            "SLACK_ALLOWED_USERS is empty — the gateway denies every user"
        )
    for user in allowed:
        if not MEMBER_ID.match(user):
            problems.append(
                f"SLACK_ALLOWED_USERS entry {user!r} is not a member ID "
                "(expected something like U01ABC2DEF3, not a username)"
            )
    return problems


def missing_scopes(granted: str) -> tuple[set[str], dict[str, str]]:
    """Split a comma-separated scope header into required and optional gaps."""
    have = {s.strip() for s in granted.split(",") if s.strip()}
    absent_optional = {
        scope: why for scope, why in OPTIONAL_SCOPES.items() if scope not in have
    }
    return REQUIRED_SCOPES - have, absent_optional


def call_auth_test(token: str) -> tuple[dict, str]:
    """Call auth.test; return the JSON body and the granted-scopes header."""
    response = httpx.post(
        "https://slack.com/api/auth.test",
        headers={"Authorization": f"Bearer {token}"},
        timeout=10.0,
    )
    return response.json(), response.headers.get("x-oauth-scopes", "")


def call_connections_open(token: str) -> dict:
    """Prove the app-level token carries connections:write."""
    response = httpx.post(
        "https://slack.com/api/apps.connections.open",
        headers={"Authorization": f"Bearer {token}"},
        timeout=10.0,
    )
    return response.json()


def main() -> int:
    env_path = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_ENV_PATH
    if not env_path.exists():
        print(f"FAIL  no env file at {env_path}")
        return 1

    mode = env_path.stat().st_mode & 0o777
    if mode & 0o077:
        print(f"WARN  {env_path} is mode {mode:o}; run: chmod 600 {env_path}")

    env = parse_env(env_path)
    problems = check_shapes(env)
    if problems:
        for problem in problems:
            print(f"FAIL  {problem}")
        return 1

    bot_token = env["SLACK_BOT_TOKEN"]
    app_token = env["SLACK_APP_TOKEN"]
    print(f"OK    bot token   {mask(bot_token)}")
    print(f"OK    app token   {mask(app_token)}")

    auth, granted = call_auth_test(bot_token)
    if not auth.get("ok"):
        print(f"FAIL  auth.test rejected the bot token: {auth.get('error')}")
        return 1
    print(f"OK    workspace   {auth.get('team')} as {auth.get('user')}")

    required_gap, optional_gap = missing_scopes(granted)
    for scope in sorted(required_gap):
        print(f"FAIL  missing required scope: {scope}")
    for scope, why in sorted(optional_gap.items()):
        print(f"WARN  optional scope {scope} not granted — no {why}")

    socket = call_connections_open(app_token)
    if not socket.get("ok"):
        print(
            "FAIL  apps.connections.open rejected the app token: "
            f"{socket.get('error')}"
        )
        return 1
    print("OK    socket mode ready")

    if required_gap:
        print(
            "\nAdd the missing scopes in Slack, then reinstall the app "
            "(Settings → Install App) — scope changes need a reinstall."
        )
        return 1

    print("\nPreflight passed. Event subscriptions are not visible to this")
    print("check; if channels stay silent, verify message.channels and")
    print("message.groups under Features → Event Subscriptions.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Detailed breakdown

  • parse_env is deliberately small. It handles the KEY=VALUE subset Hermes writes, tolerating surrounding whitespace and quotes, and ignores anything else rather than importing a dotenv library to read four keys.
  • mask exists so failure output is safe to paste. The checker prints token prefixes and lengths, never bodies, which means its output can go into a bug report or a chat message without rotating anything.
  • check_shapes catches the errors that need no network. Swapping the two tokens is a common slip and produces a baffling runtime failure; a xoxb- prefix check finds it instantly. The member-ID regex catches the other common slip, putting a username in SLACK_ALLOWED_USERS, which silently denies you from your own bot.
  • auth.test does double duty. Its JSON body proves the token is live and names the workspace it belongs to, which catches “I pasted the token from the other workspace.” Its x-oauth-scopes response header lists what was actually granted, which is the only reliable way to detect a scope added in the Slack UI but never applied because the app was not reinstalled.
  • apps.connections.open is the app-token test. It succeeds only if the token carries connections:write, which is exactly what Socket Mode needs. The call returns a single-use WebSocket URL that this tool deliberately drops on the floor; opening it is the gateway’s job.
  • The closing note is a stated limitation, not filler. Slack’s API does not expose an app’s event subscriptions to the app’s own token, so this checker cannot verify message.channels or message.groups. Saying so keeps a passing preflight from being read as a guarantee it cannot make.
  • Exit codes make it usable from a Makefile or CI. Any FAIL returns 1; warnings alone return 0.

Add the tests

Create the file

mkdir -p tests
touch tests/test_preflight.py

Add the code: tests/test_preflight.py

"""Tests for the pure logic in preflight: parsing, masking, scope diffing.

Nothing here touches the network or needs a real token — the Slack calls are
thin wrappers, and the parts worth testing are the ones that decide whether a
setup is broken.
"""

from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

import preflight  # noqa: E402


def test_parse_env_ignores_comments_and_blanks(tmp_path: Path) -> None:
    env_file = tmp_path / ".env"
    env_file.write_text(
        "# a comment\n"
        "\n"
        "SLACK_BOT_TOKEN=xoxb-abc\n"
        "  SLACK_APP_TOKEN = xapp-def  \n"
        'SLACK_ALLOWED_USERS="U01ABC2DEF3"\n'
        "NOT_A_PAIR\n",
        encoding="utf-8",
    )

    parsed = preflight.parse_env(env_file)

    assert parsed == {
        "SLACK_BOT_TOKEN": "xoxb-abc",
        "SLACK_APP_TOKEN": "xapp-def",
        "SLACK_ALLOWED_USERS": "U01ABC2DEF3",
    }


def test_mask_never_reveals_the_token_body() -> None:
    masked = preflight.mask("xoxb-fake-secretpart")

    assert "secretpart" not in masked
    assert masked.startswith("xoxb-")


def test_check_shapes_accepts_a_correct_setup() -> None:
    env = {
        "SLACK_BOT_TOKEN": "xoxb-real",
        "SLACK_APP_TOKEN": "xapp-real",
        "SLACK_ALLOWED_USERS": "U01ABC2DEF3,W02GHI4JKL5",
    }

    assert preflight.check_shapes(env) == []


def test_check_shapes_catches_swapped_tokens() -> None:
    env = {
        "SLACK_BOT_TOKEN": "xapp-oops",
        "SLACK_APP_TOKEN": "xoxb-oops",
        "SLACK_ALLOWED_USERS": "U01ABC2DEF3",
    }

    problems = preflight.check_shapes(env)

    assert any("xoxb-" in p for p in problems)
    assert any("xapp-" in p for p in problems)


def test_check_shapes_rejects_a_username_in_the_allowlist() -> None:
    env = {
        "SLACK_BOT_TOKEN": "xoxb-real",
        "SLACK_APP_TOKEN": "xapp-real",
        "SLACK_ALLOWED_USERS": "mitch",
    }

    problems = preflight.check_shapes(env)

    assert any("member ID" in p for p in problems)


def test_check_shapes_flags_an_empty_allowlist() -> None:
    env = {
        "SLACK_BOT_TOKEN": "xoxb-real",
        "SLACK_APP_TOKEN": "xapp-real",
        "SLACK_ALLOWED_USERS": "",
    }

    assert any("denies every user" in p for p in preflight.check_shapes(env))


def test_missing_scopes_reports_the_channel_history_gap() -> None:
    granted = ",".join(sorted(preflight.REQUIRED_SCOPES - {"channels:history"}))

    required_gap, _ = preflight.missing_scopes(granted)

    assert required_gap == {"channels:history"}


def test_missing_scopes_treats_optional_scopes_separately() -> None:
    granted = ",".join(sorted(preflight.REQUIRED_SCOPES))

    required_gap, optional_gap = preflight.missing_scopes(granted)

    assert required_gap == set()
    assert "assistant:write" in optional_gap

Detailed breakdown

  • The sys.path insert lets the tests import preflight from src/ without turning the project into an installable package. For a two-file tool that is the right amount of packaging.
  • test_check_shapes_catches_swapped_tokens encodes the specific mistake worth guarding: both values are present and non-empty, so any check less specific than a prefix test passes them.
  • test_check_shapes_rejects_a_username_in_the_allowlist covers the failure that is hardest to diagnose from the outside, because the gateway starts cleanly and simply ignores you.
  • test_missing_scopes_reports_the_channel_history_gap builds its input by subtracting from REQUIRED_SCOPES rather than hardcoding a list, so adding a scope to the constant cannot leave the test asserting against a stale set.
  • No test calls Slack. call_auth_test and call_connections_open are three lines each and would only be testing httpx if mocked; the logic that decides pass or fail is what these tests pin down.

Add the Makefile

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

.PHONY: help preflight test doctor manifest gateway clean

help:  ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-12s\033[0m %s\n", $$1, $$2}'

preflight:  ## Validate Slack tokens, allowlist, and OAuth scopes
	uv run python src/preflight.py

test:  ## Run the unit tests
	uv run pytest -v

doctor:  ## Ask Hermes to check its own install
	hermes doctor

manifest:  ## Regenerate the Slack app manifest after a Hermes update
	hermes slack manifest --agent-view --write

gateway:  ## Start the messaging gateway in the foreground
	hermes gateway

clean:  ## Remove caches and test artifacts
	rm -rf .pytest_cache .ruff_cache src/__pycache__ tests/__pycache__

Detailed breakdown

  • .DEFAULT_GOAL := help makes a bare make print the target list instead of running the first target by accident. With gateway and preflight in the same file, an accidental default would either hit the network or start a service.
  • The help target parses its own file. Each target’s ## comment becomes its description, so a new target is self-documenting and the help screen cannot drift from reality.
  • preflight and test go through uv run, which resolves the project’s environment from pyproject.toml and uv.lock without an activated virtualenv.
  • manifest is the target you will forget you need. Hermes adds slash commands between releases, and after hermes update the Slack app keeps advertising the old set until the manifest is regenerated and pasted back into the app.
  • gateway runs in the foreground on purpose. For a first setup you want the log in front of you. Installing it as a background service is a later step, and a different article.

Run it

make            # prints the help screen
make test
make preflight

A bare make should print the seven targets with their descriptions. make test runs the unit tests. make preflight is the one that talks to Slack; fix anything it reports as FAIL before continuing, and reinstall the Slack app if it names a missing scope.

Step 10: Start the gateway and invite the bot

With the credentials validated, the gateway is what turns them into a running connection. It stays in the foreground and logs what it is doing, which is exactly what you want the first time; installing it as a background service comes later, once you have seen it work.

hermes gateway

Four lines tell you it worked:

INFO gateway.run: Starting Hermes Gateway...
INFO hermes_plugins.slack_platform.adapter: [Slack] Authenticated as @hermes in workspace acme (team: T04QD71244R)
INFO hermes_plugins.slack_platform.adapter: [Slack] Socket Mode connected (1 workspace(s))
INFO gateway.run: Gateway housekeeping started (interval=60s)

Authenticated as proves the bot token; Socket Mode connected proves the app token. Seeing the first without the second means SLACK_APP_TOKEN is wrong or missing its connections:write scope.

The line to watch for is this one, which appears before the Slack lines when no allowlist is configured:

WARNING gateway.run: No env user allowlists configured. Messaging platforms
default to pairing/allowlist policies and will deny unknown senders unless you
configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id) or
explicitly opt in with GATEWAY_ALLOW_ALL_USERS=true plus dm_policy/group_policy:
open on the platform.

If you see that after Step 8, your .env did not parse the way you expected — the bot will connect and then ignore you. Note that the warning names TELEGRAM_ALLOWED_USERS in its example regardless of which platform you are setting up; it is generic gateway text, not a sign you configured the wrong variable.

The bot does not join channels on its own. In Slack, open the channel you want it in and invite it:

/invite @Hermes Agent

Repeat per channel. This is Slack’s model, not a Hermes limitation.

Step 11: Verify behavior, then verify the guard

Three checks, in order.

Direct message. DM the bot. In a 1:1 DM it responds to every message with no @mention needed. If you get “Sending messages to this app has been turned off,” the Messages tab is disabled: Features → App Home → Show Tabs → Messages Tab, plus the checkbox allowing slash commands and messages from that tab.

Channel mention. In a channel the bot has been invited to:

@Hermes Agent what time is it?

In channels the bot only answers when mentioned, and it replies in a thread attached to your message. Once it has an active session in that thread, follow-up replies in the thread no longer need a mention. If DMs work and channels stay silent, the cause is almost always missing message.channels or message.groups event subscriptions, which is the one thing Step 9’s preflight cannot see.

The guard. Ask the bot to run the canary command from Step 5:

Run whoami and tell me the output.

The deny rule should block it before anything executes, and the agent should report that the command was blocked rather than retrying or rephrasing it. That is the check worth doing: you now know the deny list is loaded and matching, and you learned it from a command that would have printed your username if the guard had failed.

Then take the canary out. Edit ~/.hermes/config.yaml, delete the "*whoami*" line, and keep the real rules. Changes apply immediately with no restart, so ask the bot to run whoami once more to confirm the rule is gone and the mechanism still works.

While you are in Slack, /help lists every Hermes command as a native slash command. One quirk worth knowing early: Slack blocks native slash commands inside thread replies, so Hermes accepts a leading ! as an alternate prefix. !stop works in a thread where /stop does not.

Troubleshooting

SymptomCause and fix
hermes: command not found~/.local/bin is not on PATH. Add it and source ~/.zshrc.
Works in DMs, silent in channelsMissing message.channels / message.groups event subscriptions. Add them under Features → Event Subscriptions, save, and reinstall the app.
“Sending messages to this app has been turned off”Messages tab disabled under Features → App Home.
Gateway starts, bot ignores youYour member ID is not in SLACK_ALLOWED_USERS, or a username was used instead of an ID. make preflight catches this.
invalid_auth from preflightToken belongs to a different workspace, or was regenerated in Slack after you copied it.
Scope added in Slack but still reported missingThe app was not reinstalled. Scope changes take effect only on reinstall.
Slash commands missing after hermes updateRegenerate and repaste the manifest: make manifest.
Context length is only N tokens, then below the minimum 64,000 requiredStartup fails; this is a refusal, not a warning. On a local server it is usually the server’s configured window, not the model’s: restart with OLLAMA_CONTEXT_LENGTH=64000 ollama serve, or set model.context_length in config.yaml to the real window.
Another gateway instance (PID N) started during our startup. Exiting to avoid double-running.Two gateways raced for the PID file and this one stood down before opening any connection. Expected if you started a second instance while the first was coming up, or ran hermes gateway on top of an installed service. hermes gateway status shows what is running.
Installer says Playwright “does not support automatic dependency installation on macos”Expected on macOS and not an error. The message targets Linux distributions needing system libraries; Chromium still downloads and browser tools still work.
Settings under terminal: reverted to defaults after editing config.yamlA second top-level terminal: key was appended. YAML keeps the last and discards the first silently. Merge them into one block, and see Step 5.
Anything elsehermes doctor walks its full checklist and closes with a numbered issue count; hermes doctor --fix repairs what it can.

Recap

You installed Hermes Agent from an installer you read first, verified a real conversation in the terminal before adding anything to it, and set the approval mode, deny rules, and session isolation while your keyboard was still the only way in. Then you created a Slack app from a generated manifest, wired it up over Socket Mode so no inbound port was involved, validated the credentials and scopes with a checker that fails loudly instead of leaving you to guess, and confirmed both that the bot responds and that the guard blocks.

What is worth carrying forward: the agent’s boundaries are configuration, and they are worth setting before the first message arrives rather than after the first surprise. terminal.backend: local is the honest weak point of this setup. It is a reasonable place to learn, and the wrong place to leave an agent that runs unattended.

Where to go next:

  • Move to the Docker backend (hermes config set terminal.backend docker) before this agent runs without you watching. Remember the tradeoff from Step 5: container backends skip the approval and deny-rule stack, because the container is the boundary instead.
  • Install the gateway as a service with hermes gateway install so it survives a logout, which is the first step toward an always-on host.
  • Give it your own tools over MCP. Hermes speaks the Model Context Protocol, so a server you already have works here; see Build an MCP Server with FastMCP and Python for the server side, and Hermes’ MCP integration guide for the wiring.
  • Add a second channel. The gateway supports Telegram, Discord, WhatsApp, Signal, email, and more from the same agent and the same session store, with hermes gateway setup walking each one.