hermes-agent: onboard Nous-Hermes-on-Goose to homelab clients

`bootstrap.sh --with-hermes` installs the Goose CLI, drops a Goose
config pinning the OpenRouter provider + Nous Hermes model + the
homelab MCP extension, symlinks `bin/hermes` and HERMES.md, and links
HERMES.md as `.goosehints` so the persona is injected as the system
prompt every session.

`bin/hermes` decrypts `secrets/openrouter-api-key.yaml` via the existing
`homelab secret` flow and execs `goose session`.

`homelab client add --with-hermes` grants the new sops secret to the
host's age_pubkey at finalize time (parallel to the existing
shared-secrets grant). `client remove` revokes it.

`operations/hermes-agent.md` covers the end-to-end flow, verification,
troubleshooting, and queues one follow-up: the MCP server still runs
SSE-only but Goose 1.x deprecated SSE — the Goose config targets
`streamable_http` and the `homelab` extension won't connect until
`mcp/server.py` migrates. The `developer` extension (shell + edit +
`homelab` CLI) carries the agent in the meantime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 01:18:43 +02:00
parent 04d1f39e7b
commit 4560e25bd7
7 changed files with 432 additions and 8 deletions

View File

@@ -62,4 +62,14 @@ creation_rules:
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
- path_regex: ^secrets/openrouter-api-key\.yaml$
# OpenRouter API key consumed by the `hermes` wrapper (bin/hermes) when
# spawning a Goose session. Recipients are any host that should run a
# Nous-Hermes agent. Add a host's age_pubkey here, then
# `sops updatekeys -y secrets/openrouter-api-key.yaml`.
# See operations/hermes-agent.md.
age: >-
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
# webhook noop 2026-05-20T18:16:57+02:00

36
bin/hermes Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# hermes — launch a Goose session pre-wired with the homelab persona,
# OpenRouter (Nous Hermes) provider, and the homelab MCP server.
#
# See operations/hermes-agent.md for the full onboarding flow.
set -euo pipefail
die() { echo "hermes: $*" >&2; exit 1; }
command -v goose >/dev/null \
|| die "goose binary not found — re-run bootstrap.sh with --with-hermes"
command -v homelab >/dev/null \
|| die "homelab CLI not found — is this client bootstrapped?"
# Decrypt OpenRouter API key.
# `homelab secret` re-execs via sudo for non-root users (age key is 0600 root).
SECRET_YAML=$(homelab secret openrouter-api-key 2>&1) || \
die "could not decrypt secrets/openrouter-api-key.yaml — this host probably
isn't a recipient yet. See operations/hermes-agent.md ('Granting the OpenRouter
key to a new host'). sops output:
${SECRET_YAML}"
API_KEY=$(printf '%s' "$SECRET_YAML" | python3 -c \
'import sys, yaml; print(yaml.safe_load(sys.stdin)["api_key"])')
case "$API_KEY" in
PLACEHOLDER_*|"")
die "openrouter-api-key.yaml still contains the placeholder; operator
must run \`sops secrets/openrouter-api-key.yaml\` on hubris to insert a real
\`sk-or-...\` key and push the change." ;;
esac
export OPENROUTER_API_KEY="$API_KEY"
exec goose session "$@"

View File

@@ -199,6 +199,12 @@ SHARED_SECRETS = [
("secrets/netbird-authentik-oidc.yaml", "^secrets/netbird-authentik-oidc\\.yaml$"),
]
# Secrets granted only to hosts that opt into running the Hermes agent
# (via `homelab client add --finalize-pubkey ... --with-hermes`).
HERMES_SECRETS = [
("secrets/openrouter-api-key.yaml", "^secrets/openrouter-api-key\\.yaml$"),
]
# -------- comment-preserving inventory.yaml edits --------
# yaml.safe_load + safe_dump round-trips strip every comment, which is fine
@@ -360,11 +366,15 @@ def _add_recipient_to_sops_policy(sops_path: Path, path_regex_pattern: str, pubk
return True
def _grant_shared_secrets(pubkey: str) -> None:
"""Add `pubkey` to the recipient list of every shared secret + re-key."""
def _grant_shared_secrets(pubkey: str, secrets: list[tuple[str, str]] = SHARED_SECRETS) -> None:
"""Add `pubkey` to the recipient list of every listed secret + re-key.
`secrets` defaults to SHARED_SECRETS; pass HERMES_SECRETS to grant the
Hermes-only set.
"""
sops_path = CONTEXT / ".sops.yaml"
env = {**os.environ, "SOPS_AGE_KEY_FILE": str(AGE_KEY)}
for rel_path, pattern in SHARED_SECRETS:
for rel_path, pattern in secrets:
target = CONTEXT / rel_path
if not target.exists():
print(f" skipping {rel_path}: file does not exist yet")
@@ -440,11 +450,14 @@ def _remove_recipient_from_sops_policy(sops_path: Path, path_regex_pattern: str,
return True
def _revoke_shared_secrets(pubkey: str) -> None:
"""Remove `pubkey` from every shared-secret rule + re-key the files."""
def _revoke_shared_secrets(pubkey: str, secrets: list[tuple[str, str]] = SHARED_SECRETS) -> None:
"""Remove `pubkey` from every listed secret rule + re-key the files.
Defaults to SHARED_SECRETS; pass HERMES_SECRETS to revoke the Hermes-only set.
"""
sops_path = CONTEXT / ".sops.yaml"
env = {**os.environ, "SOPS_AGE_KEY_FILE": str(AGE_KEY)}
for rel_path, pattern in SHARED_SECRETS:
for rel_path, pattern in secrets:
target = CONTEXT / rel_path
if not target.exists():
continue
@@ -1033,7 +1046,8 @@ def cmd_client_add(args: argparse.Namespace) -> int:
return subprocess.call(["sudo", "-E", sys.argv[0], "client", "add"]
+ ([args.name] if args.name else [])
+ (["--finalize-pubkey", args.finalize_pubkey]
if args.finalize_pubkey else []))
if args.finalize_pubkey else [])
+ (["--with-hermes"] if args.with_hermes else []))
name = args.name
inv = inventory()
if not args.finalize_pubkey:
@@ -1054,6 +1068,7 @@ def cmd_client_add(args: argparse.Namespace) -> int:
print(f" 2. On {name}: curl -fsSL <gitea>/dtoro/Homelab-Docs/raw/main/bootstrap.sh | sudo bash")
print(f" 3. bootstrap prints an age pubkey — bring it back here and run:")
print(f" homelab client add {name} --finalize-pubkey <age1...>")
print(f" (append --with-hermes to also grant the Hermes agent's OpenRouter key.)")
return 0
# finalize_pubkey path
@@ -1065,8 +1080,13 @@ def cmd_client_add(args: argparse.Namespace) -> int:
print(f"set age_pubkey for {name}")
print("granting shared secrets...")
_grant_shared_secrets(pubkey)
commit_subject = f"client-add: {name} (finalize age_pubkey + grant shared secrets)"
if args.with_hermes:
print("granting hermes-only secrets...")
_grant_shared_secrets(pubkey, HERMES_SECRETS)
commit_subject = f"client-add: {name} (finalize age_pubkey + grant shared + hermes secrets)"
push_inventory(
f"client-add: {name} (finalize age_pubkey + grant shared secrets)",
commit_subject,
extra_paths=[".sops.yaml", "secrets/"],
)
print(f"finalized {name}.")
@@ -1103,6 +1123,9 @@ def cmd_client_remove(args: argparse.Namespace) -> int:
if pubkey:
print("revoking shared secrets...")
_revoke_shared_secrets(pubkey)
# Also revoke from hermes-only secrets; idempotent if the pubkey
# was never on those rules (logs a "not present" warning, no harm).
_revoke_shared_secrets(pubkey, HERMES_SECRETS)
else:
print(f" note: no age_pubkey recorded for {name} — skipping sops re-key")
@@ -1518,6 +1541,10 @@ def main() -> int:
csub_add.add_argument("name")
csub_add.add_argument("--finalize-pubkey", default=None,
help="set/update age_pubkey for an already-added client")
csub_add.add_argument("--with-hermes", action="store_true",
help="also grant secrets/openrouter-api-key.yaml so this "
"host can run the Hermes agent (see "
"operations/hermes-agent.md). Combine with --finalize-pubkey.")
csub_add.set_defaults(func=cmd_client_add)
csub_rm = csub.add_parser("remove")
csub_rm.add_argument("name")

View File

@@ -5,6 +5,7 @@
# curl -fsSL https://git.hubris.network/dtoro/Homelab-Docs/raw/main/bootstrap.sh \
# | sudo bash
# curl ... | sudo bash -s -- --with-mcp # also wire Claude's .mcp.json
# curl ... | sudo bash -s -- --with-hermes # also install Goose + Hermes wrapper
# curl ... | sudo bash -s -- --dry-run # show what would happen
# curl ... | sudo bash -s -- --no-secrets # skip age-key issuance
#
@@ -23,8 +24,11 @@ CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab-context}"
ISSUANCE_URL_NETBIRD="${HOMELAB_ISSUANCE_NETBIRD:-https://secrets.hubris.network/issue}"
ISSUANCE_URL_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-https://secrets.hubris.network/issue}"
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/sse}"
HERMES_MCP_URI="${HOMELAB_HERMES_MCP_URI:-https://mcp.hubris.network/mcp}"
HERMES_MODEL="${HOMELAB_HERMES_MODEL:-nousresearch/hermes-4-405b}"
WITH_MCP=0
WITH_HERMES=0
DRY_RUN=0
NO_SECRETS=0
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
@@ -34,6 +38,7 @@ GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}"
while [ $# -gt 0 ]; do
case "$1" in
--with-mcp) WITH_MCP=1; shift ;;
--with-hermes) WITH_HERMES=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--no-secrets) NO_SECRETS=1; shift ;;
--gitea-token) GITEA_TOKEN="$2"; shift 2 ;;
@@ -403,6 +408,116 @@ PYEOF
fi
fi
# -------- Hermes (Goose + Nous Hermes) wiring --------
# Installs the Goose CLI binary system-wide, symlinks the `hermes` wrapper
# and HERMES.md persona, and drops a Goose config that pins the OpenRouter
# provider, the Nous Hermes model, and the homelab MCP extension.
# See operations/hermes-agent.md.
if [ "$WITH_HERMES" -eq 1 ]; then
# Resolve the operator's home (SUDO_USER under `sudo bash`).
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
H_USER="$SUDO_USER"
H_HOME=$(eval echo "~$SUDO_USER")
else
H_USER="root"
H_HOME="$HOME"
fi
# 1. Install Goose binary at /usr/local/bin/goose (idempotent).
if ! command -v goose >/dev/null 2>&1; then
echo "[bootstrap] installing Goose CLI"
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would run upstream goose installer and symlink to /usr/local/bin/goose"
else
# Upstream installer drops the binary at ~/.local/bin/goose for the
# invoking user. We run it as $H_USER then symlink system-wide.
sudo -u "$H_USER" \
env CONFIGURE=false \
bash -c 'curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash'
if [ -x "$H_HOME/.local/bin/goose" ]; then
ln -sfn "$H_HOME/.local/bin/goose" /usr/local/bin/goose
else
echo "[bootstrap] WARNING: goose binary not found at $H_HOME/.local/bin/goose after install" >&2
fi
fi
else
echo "[bootstrap] goose already installed: $(command -v goose)"
fi
# 2. Symlink hermes wrapper.
echo "[bootstrap] linking hermes CLI to /usr/local/bin/hermes"
run "ln -sfn '$CLONE_DIR/bin/hermes' /usr/local/bin/hermes"
# 3. Symlink HERMES.md persona. The hermes wrapper does not need it — the
# Goose config below references the canonical clone path — but operators
# frequently `cat /root/HERMES.md` to inspect the persona, mirroring the
# AGENTS.md convention above.
case "$OS" in
Linux) HERMES_LINK=/root/HERMES.md ;;
Darwin) HERMES_LINK=/etc/HERMES.md ;;
esac
run "ln -sfn '$CLONE_DIR/HERMES.md' '$HERMES_LINK'"
echo "[bootstrap] linked HERMES.md → $HERMES_LINK"
# 4. Drop the Goose config. Idempotent YAML merge — preserves any keys the
# operator added by hand, overwrites only the keys we manage.
GOOSE_DIR="$H_HOME/.config/goose"
GOOSE_CONFIG="$GOOSE_DIR/config.yaml"
GOOSEHINTS="$GOOSE_DIR/.goosehints"
run "mkdir -p '$GOOSE_DIR'"
PY_GOOSE_MERGE=$(cat <<PYEOF
import os, sys
try:
import yaml
except ImportError:
print("PyYAML required", file=sys.stderr); sys.exit(2)
path = "$GOOSE_CONFIG"
mcp_uri = "$HERMES_MCP_URI"
model = "$HERMES_MODEL"
cfg = {}
if os.path.exists(path):
with open(path) as f:
try:
cfg = yaml.safe_load(f) or {}
except Exception:
cfg = {}
cfg["GOOSE_PROVIDER"] = "openrouter"
cfg["GOOSE_MODEL"] = model
cfg.setdefault("GOOSE_MODE", "smart_approve")
cfg.setdefault("extensions", {})
cfg["extensions"]["developer"] = {
"bundled": True, "enabled": True, "name": "developer",
"timeout": 300, "type": "builtin",
}
cfg["extensions"]["homelab"] = {
"enabled": True, "name": "homelab",
"description": "Read-only homelab context tools (FastMCP).",
"type": "streamable_http", "uri": mcp_uri, "timeout": 60,
}
with open(path, "w") as f:
yaml.safe_dump(cfg, f, sort_keys=False)
print("[bootstrap] merged Goose config at", path)
PYEOF
)
if [ "$DRY_RUN" -eq 1 ]; then
echo "+ would merge Goose config at $GOOSE_CONFIG"
else
python3 -c "$PY_GOOSE_MERGE"
chown -R "$H_USER" "$GOOSE_DIR"
fi
# 5. Symlink HERMES.md as the global .goosehints — Goose injects it into
# the system prompt on every session start.
run "ln -sfn '$CLONE_DIR/HERMES.md' '$GOOSEHINTS'"
if [ "$DRY_RUN" -eq 0 ]; then
chown -h "$H_USER" "$GOOSEHINTS" 2>/dev/null || true
fi
fi
# -------- netbird tuning (skip per-session SSO for ssh into mesh peers) --------
# Apply the SSH JWT cache TTL so `ssh ... .netbird.selfhosted` doesn't trigger
# device-code SSO on every connection. Flag added in netbird 0.71.x

View File

@@ -5,6 +5,10 @@ joins the cross-client context system: a `/opt/homelab-context/` clone of
this repo that auto-syncs every 5 min, a per-client age key for SOPS
decryption, the `homelab` CLI, and an MCP endpoint in Claude Code's config.
> Onboarding a Nous-Hermes-powered Goose agent on top of standard enrollment?
> See [hermes-agent.md](./hermes-agent.md). It uses the same `bootstrap.sh`
> with an additional `--with-hermes` flag.
Architecture in [project_homelab_context_plan](https://… memory link); the
operational reference is here.
@@ -274,6 +278,9 @@ The CLI prints a follow-up checklist that the operator must do manually:
## Changelog
### 2026-05-31 — cross-link to hermes-agent.md
Added a sibling page covering Nous-Hermes-on-Goose enrollment ([hermes-agent.md](./hermes-agent.md)) and noted it at the top of this page. The Hermes flow extends `bootstrap.sh` with `--with-hermes` and `homelab client add` with the same flag; it does not change the underlying enrollment steps documented here.
### 2026-05-21 — netbird-ssh JWT issuer + username + LAN-fallback troubleshooting rows
Added three rows to the troubleshooting table covering issues surfaced during the netbird vanilla migration: (1) post-migration SSH JWT validator cache stuck on old Dex issuer (full `systemctl stop/start` required, not `restart`), (2) `user not found` from netbird-ssh's local-username default (use explicit `root@`), and (3) homelab CLI's LAN→netbird-FQDN fallback for off-LAN operators. Companion code change: per-host `ssh.user` field in `inventory.yaml` + `homelab` CLI's `ssh_target()` helper.

199
operations/hermes-agent.md Normal file
View File

@@ -0,0 +1,199 @@
# Hermes agent — Nous-Hermes-powered Goose sessions on a homelab client
Onboards [Nous Research's Hermes](https://nousresearch.com/) (a fine-tuned
Llama variant) as a working terminal agent on a homelab client. Builds on top
of standard client enrollment (see [agent-enrollment.md](./agent-enrollment.md))
— this page covers only the Hermes-specific additions.
The agent runs as a [Goose](https://goose-docs.ai/) session. Goose provides:
- The chat loop, multi-turn history, and streaming
- The OpenRouter provider that routes to Nous Hermes
- The built-in `developer` extension (shell + file editor — same surface Claude
Code has)
- A remote MCP extension pointed at `mcp.hubris.network` for read-only
homelab context (`list_lxcs`, `tail_log`, `search_docs`, etc.)
The persona is `/opt/homelab-context/HERMES.md`, symlinked as Goose's global
`.goosehints` so it's injected into the system prompt on every session.
## Prerequisites
| Requirement | How |
| --- | --- |
| Standard enrollment complete (`homelab whoami` works) | [agent-enrollment.md](./agent-enrollment.md) |
| `secrets/openrouter-api-key.yaml` exists with a real `sk-or-...` value | See "Seeding the OpenRouter key" below |
| The host's `age_pubkey` is on the openrouter-api-key.yaml sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-hermes` |
## Onboarding flow
```bash
# 1. On hubris (or any enrolled client): reserve the inventory entry.
homelab client add new-machine
# 2. Join new-machine to Netbird (setup-key or OIDC).
# 3. On new-machine: bootstrap with --with-hermes.
TOKEN=... # gitea PAT, read:repository
curl -fsSL -u "dtoro:$TOKEN" \
https://git.hubris.network/dtoro/Homelab-Docs/raw/branch/main/bootstrap.sh \
-o /tmp/bootstrap.sh
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp --with-hermes
# 4. Back on hubris: finalize the age pubkey AND grant the Hermes secret.
homelab client add new-machine \
--finalize-pubkey age1... \
--with-hermes
# 5. Wait ≤5 min for sync, then on new-machine:
hermes "what LXCs are running?"
```
The bootstrap `--with-hermes` flag does five things, all idempotent:
1. Downloads the latest Goose binary into the operator's `~/.local/bin/goose`
(upstream installer) and symlinks `/usr/local/bin/goose` to it.
2. Symlinks `/opt/homelab-context/bin/hermes``/usr/local/bin/hermes`.
3. Symlinks `/opt/homelab-context/HERMES.md``/root/HERMES.md` (Linux) or
`/etc/HERMES.md` (macOS) for `cat`-as-operator convenience.
4. Drops `~/.config/goose/config.yaml` pinning the provider, model, and
extensions (preserves any keys the operator added by hand).
5. Symlinks `~/.config/goose/.goosehints` → HERMES.md, so the persona is
injected as the system prompt on every session.
## Seeding the OpenRouter key
The first time anyone enrolls with `--with-hermes`, the encrypted file
`secrets/openrouter-api-key.yaml` contains a placeholder. On hubris (or any
existing recipient):
```bash
sops secrets/openrouter-api-key.yaml
# editor opens; replace api_key value with the real sk-or-... key, save, close.
git -C /opt/homelab-context add secrets/openrouter-api-key.yaml
git -C /opt/homelab-context commit -m 'openrouter-api-key: seed real key'
git -C /opt/homelab-context push
```
Until this step happens, `hermes …` exits with `openrouter-api-key.yaml still
contains the placeholder`. Subsequent enrollees get the real key automatically
via `--with-hermes` (which adds them as a sops recipient on
`secrets/openrouter-api-key.yaml`).
## Granting the OpenRouter key to an already-enrolled host
If a host was enrolled without `--with-hermes` and you want to add it later:
```bash
# On hubris:
PUBKEY=$(homelab whoami --hostname <host> | grep age_pubkey | awk '{print $2}')
homelab client add <host> --finalize-pubkey "$PUBKEY" --with-hermes
```
`--finalize-pubkey` is required by the existing flow even when the pubkey is
unchanged — it's also the trigger that runs the sops grant.
After ≤5 min sync the host can decrypt the key. Bootstrap doesn't need to
re-run; only the secret recipient list changed.
## Verifying
```bash
homelab whoami # standard enrollment OK
homelab secret openrouter-api-key | head -c 8 # decrypts (prints `api_key:`)
which goose && which hermes # binaries present
goose info -v # provider/model wiring sane
hermes "what LXCs are running?" # interactive Goose session
# Non-interactive smoke test:
echo "List the homelab MCP tools you have available" | hermes
```
## Configuration
The bootstrap-managed keys in `~/.config/goose/config.yaml`:
```yaml
GOOSE_PROVIDER: openrouter
GOOSE_MODEL: nousresearch/hermes-4-405b
GOOSE_MODE: smart_approve # asks before destructive tool calls
extensions:
developer:
type: builtin
bundled: true
enabled: true
name: developer
timeout: 300
homelab:
type: streamable_http
enabled: true
name: homelab
uri: https://mcp.hubris.network/mcp
timeout: 60
```
Override via env on a single bootstrap run:
```bash
HOMELAB_HERMES_MODEL=nousresearch/hermes-3-llama-3.1-405b \
HOMELAB_HERMES_MCP_URI=https://mcp.hubris.network/mcp \
sudo bash /tmp/bootstrap.sh --with-hermes
```
Any keys you add by hand (e.g. `GOOSE_TEMPERATURE`, extra `extensions.*`) are
preserved across re-bootstraps — the merge only overwrites the keys it manages.
## Tool permissions
`GOOSE_MODE: smart_approve` is the bootstrap default: Goose runs read-only
shell commands without prompting and asks for confirmation before destructive
ones. To make the agent fully unattended (e.g. for scheduled jobs), set
`GOOSE_MODE: auto` in `~/.config/goose/config.yaml`. To require confirmation on
every tool call, use `approve`. See
[goose-permissions](https://goose-docs.ai/docs/guides/managing-tools/goose-permissions/).
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `hermes: could not decrypt secrets/openrouter-api-key.yaml` | Host isn't a recipient on the sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-hermes` from hubris |
| `hermes: openrouter-api-key.yaml still contains the placeholder` | No real key has been seeded yet | See "Seeding the OpenRouter key" above |
| Goose hangs on first `hermes` invocation with no output | Goose's interactive `configure` ran on first launch and is awaiting input | Re-run; the installer is supposed to skip it (CONFIGURE=false). If it persists, run `goose configure` once manually in a real terminal to commit the config. |
| `homelab` extension fails to connect / no MCP tools listed | MCP server still runs SSE-only; Goose requires `streamable_http`. See follow-up #1 below. | Either: (a) migrate the FastMCP server to streamable_http (one-line change in `mcp/server.py``mcp.run(transport="streamable_http")` — then redeploy), or (b) accept that the agent works via the developer extension alone (shell + `homelab` CLI cover everything MCP would). |
| `goose: command not found` after bootstrap | Upstream installer dropped binary in `~/.local/bin/` but `/usr/local/bin/goose` symlink didn't land | Re-run bootstrap with `--with-hermes`; the symlink step is at the end of the install block. If still missing, `ln -sfn ~/.local/bin/goose /usr/local/bin/goose` manually. |
| Tool calls hit OpenRouter rate limits | One shared key across many hosts | Future: per-host keys; for now, see the rate-limits guide referenced in `goose info -v`. |
## Cross-references
- [agent-enrollment.md](./agent-enrollment.md) — base client onboarding the
Hermes flow assumes is done.
- [`HERMES.md`](../HERMES.md) — the persona the Hermes agent reads on every
session start (via `~/.config/goose/.goosehints`).
- [`bin/hermes`](../bin/hermes) — the wrapper that decrypts the OpenRouter key
and execs `goose session`.
- [`bootstrap.sh`](../bootstrap.sh) — the `--with-hermes` flag's install block.
## Follow-ups
1. **Migrate the MCP server to streamable_http.** Goose 1.x deprecated SSE
(`"SSE transport is no longer supported - kept only for config file
compatibility"` in `crates/goose/src/agents/extension.rs`). Our FastMCP
server at `mcp/server.py:336` still calls `mcp.run(transport="sse")`. Until
that's changed, the `homelab` MCP extension in Goose will fail to connect.
The developer extension (shell + edit) covers most ops without it; this is
a polish item, not a blocker.
2. **Per-host OpenRouter keys** for billing attribution. Today all Hermes
hosts share one key.
3. **Pin the model version** rather than tracking `nousresearch/hermes-4-405b`
directly — OpenRouter periodically rotates the underlying weights.
4. **Local-inference fallback** (ollama / vllm) once the homelab has a GPU
node. The wrapper, persona, and MCP wiring stay unchanged; only
`GOOSE_PROVIDER`/`GOOSE_MODEL` change.
## Changelog
### 2026-05-31 — initial page
Captures the Hermes-on-Goose onboarding flow added in the same commit as
`bootstrap.sh --with-hermes`, `bin/hermes`, the sops rule for
`secrets/openrouter-api-key.yaml`, and the `homelab client add --with-hermes`
extension. MCP streamable_http migration is queued as follow-up #1.

View File

@@ -0,0 +1,30 @@
api_key: ENC[AES256_GCM,data:ps6aTT939SWpgyUoMMIj6YsJG3UnJFBdRKuESpoMeyphB9QnrkqSb5Y/kXI=,iv:6KxZTpPUR2GmPzr5opMP4Wp/yPIQqE9AWtAO5xBDGTI=,tag:qfRt1AlQ97PeHMsGmXnqsQ==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUZEU0QWtxb0RMMkNqTHI3
d25ONHF2Z2F6L0c4VWd6amMvYkQ0NU5YeEh3ClpBSEt5bXNJVmF5Q2pzNGdzVWVC
MzFDSTk2YlpyWkxIdDg0RmFxR21EMzgKLS0tIE1GRW52Wk10eXppM05XSDMrc3RH
eWQ5TFR0R29RWUxsbVhlWm1TVkFvb0UKwv6OafGdw06J21tQoUlvNI0aLVvl2CVm
nwbltuhQvWdFPVBRP/Cx0eK7Vh80tJrnOfXeGpNVgiCs2lpxBlUR2g==
-----END AGE ENCRYPTED FILE-----
- recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMZVdubENvWkxGWmNnVks0
NFJYQVBKMnJJZ3FEUDVJWXl2WitMTDFYdEd3CmJQaSt4bTZyUVlBZHgybXRhbWky
UGIzNkwzUzNZaWN3WmNKSUkxQkl0OTAKLS0tIENyNlphYmVRV3BUNU1FQzd0QStN
V0JLNU5RT09RVjd2OTJUTWMvek9NaTQK0R0OfQnm7AhIeroefldUNDsgDHX1cDmk
GybdGEoM8j8Is8GLc6Gk+yQmz7yNvVfYBkaaFsH3Crz3HupuyXI59g==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-05-30T23:08:58Z"
mac: ENC[AES256_GCM,data:68i7qPK7zJXDspmVYfTVJGd141ldXeiPEHK/tsX3p9QHBSP8otXwAzaEqcjNG83dXhrse9CRzzyJmrLm7EO714hZyHKh6MKZb/BOuiwnMCZse0qbK55ghkzTQLMkeSuIM2KsN396mlH/Ehd/hQV6oC6OPq2RQUs/aJt55ESutLc=,iv:kGPKf9e6w1vz7soEyDL7mGQCBkrxiG0Wvm7FHEYp3jc=,tag:zHd7qoIx9Fei2Aabgsztuw==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.9.4