Skip to content

Hermes — Self-Hosted AI Personal Assistant

Hermes — Self-Hosted AI Personal Assistant

Section titled “Hermes — Self-Hosted AI Personal Assistant”

This is an operator’s guide to standing up Hermes — a self-hosted, always-on AI personal assistant (as opposed to a coding agent like Claude Code or Mistral Vibe). Hermes lives in a chat group and/or an email inbox, holds long-term memory, manages a calendar, searches the web, and pushes urgent alerts to your phone. You talk to it like a chief-of-staff; it takes actions with real tools instead of just answering.

Everything here is provider-agnostic where possible and uses placeholder values (assistant@example.com, notify.example.com, 198.51.100.10). Substitute your own.

Hermes (the Nous “Hermes” agent lineage) is an agent runtime that connects a language model to gateway platforms (chat, email, etc.) and a set of tools (terminal, web search, calendar, memory, custom skills). The agent:

  • receives a message on a gateway (a chat group, a DM, an email),
  • reasons with its configured model,
  • calls tools to do things (run a command, send an email, add a calendar event, push an alert), and
  • replies with what it did.

It runs headless in a container, so it’s available 24/7 without your laptop.

┌─────────────────────────────────────────────┐
Your phone / chat │ Host (VM or LXC, e.g. on Proxmox) │
───────────────► │ │
chat group, email │ ┌───────────┐ model API (Mistral/OpenAI)│
│ │ Hermes │──────────────────────────────────► cloud LLM
push alerts ◄────── │ │ agent │ ┌──────────┐ │
(ntfy app) │ │ container │──►│ memory │ (vector + LLM) │
│ └─────┬─────┘ └──────────┘ │
│ │ tools: │
│ ├─► email CLI ──► your mailbox(es) │
│ ├─► calendar (CalDAV sidecar) │
│ ├─► web search (SearXNG) │
│ └─► push (ntfy) ──┐ │
│ │ │
│ Cloudflare Tunnel ◄──────┴── localhost only │
└───────────────│─────────────────────────────┘
public HTTPS (no open ports)

Design principles

  • Bind services to localhost/private IPs; expose via Cloudflare Tunnel, not open ports. See Cloudflare Tunnels.
  • One container per concern, on a shared Docker network. The agent talks to sidecars (memory, calendar, search, push) by service name.
  • Secrets live server-side only — a gitignored .env (chmod 600), never committed, referenced from config as ${VAR}.
  • A Linux host: a VM or Proxmox LXC container with Docker + Compose. 2 GB RAM is enough for the agent alone; 4 GB+ if you also self-host memory/search.
  • A baseline-hardened server — see Linux Server Initial Setup.
  • A model API key (Mistral and/or OpenAI), or a local model endpoint.
  • A Cloudflare Tunnel for any service the phone needs to reach (push, calendar).
  • A chat platform for the assistant to live in (a self-hostable messenger group works well) and/or a dedicated mailbox.

Pull the agent image, give it a config directory, and a gitignored .env for secrets.

# compose.yml (excerpt)
services:
hermes:
image: ghcr.io/nousresearch/hermes:latest # or your chosen agent image
container_name: hermes
restart: unless-stopped
env_file: ./data/.env # secrets, chmod 600, gitignored
volumes:
- ./config:/config:ro # config.yaml, SOUL.md, skills
- ./data:/home/hermes/.hermes # runtime state, notes, memory
networks: [assistant-net]
user: "10001:10001" # never run the agent as root
networks:
assistant-net:
external: true
Terminal window
mkdir -p data config && chmod 700 data
cp .env.example data/.env && chmod 600 data/.env # then fill in your keys
docker compose up -d
docker logs -f hermes

Don’t hardwire one model. Configure a default, automatic fallbacks, and a deep-reasoning tier you can switch to on demand. This controls both cost and quality.

# config.yaml (excerpt)
model:
provider: mistral
name: devstral-medium-latest # default day-to-day brain
fallback: [mistral-medium-latest, mistral-small-latest]
auxiliary:
name: mistral-small-latest # cheap model for vision/summarise/compression

For hard problems, wire a high-end model (e.g. a top OpenAI or Anthropic model) as a deep tier and toggle it with a small script that swaps the model block and restarts — keeping cheap models for routine traffic. Tip: give the deep tier its own rate-capped API key so a runaway loop can’t drain your main key.

LLM agents have two memory problems: they forget between sessions, and their auto-summarised “memory” can drift into confidently wrong facts. Solve it in layers:

  1. A long-term memory service (e.g. a vector-store memory backend) for general recall. These typically need a one-time DB migration before first start — if it crash-loops on boot, run the migration (alembic upgrade head or equivalent) first. They also often need a real embeddings key (don’t point an OpenAI-embeddings module at a Mistral key — it 401-loops).

  2. Durable Markdown note pages under the runtime dir — the reliable second brain:

    ~/.hermes/notes/trips/<trip>.md # itineraries, confirmation codes
    ~/.hermes/notes/projects/<x>.md
    ~/.hermes/notes/people.md
    ~/.hermes/expenses/<period>.md # logged receipts

Let the assistant send and receive as itself (e.g. assistant@example.com) and optionally manage your personal mailboxes. Two ways:

  • A gateway “email” platform: the agent polls an inbox and auto-replies to allowlisted senders — you email the assistant, it answers like an assistant.
  • A CLI email tool the agent drives from its terminal for read/reply/forward/move/ archive/delete (a TUI/CLI mail client is far more reliable here than most email MCPs, which frequently lack a move/archive verb or break under headless npx).

Give the agent a calendar it fully controls via a CalDAV/CardDAV sidecar (e.g. Radicale) and a DAV tool/MCP. This sidesteps the common situation where your main mail/groupware stack has DAV locked behind an SSO/Bearer wall that rejects plain passwords. The agent gets working calendar + contacts immediately; you can subscribe to the same calendar from your phone by exposing the DAV port through the tunnel.

A chief-of-staff doesn’t only answer when asked — it surfaces things on a schedule. Use the agent runtime’s cron to push recurring briefs to your chat: a morning brief (today’s calendar + tasks due + urgent unread mail + today’s travel), an evening prep (tomorrow), a midday inbox triage (read-only — alert only if urgent), and a weekly review (open tasks + week ahead + un-filed expenses). Keep each brief short and tell it (in SOUL.md) to skip empty sections and never dump its memory profile.

Tasks & follow-ups — one Markdown backlog is the source of truth

Section titled “Tasks & follow-ups — one Markdown backlog is the source of truth”

Have the agent capture every actionable item (from chat, an email, a receipt) into a single ~/.hermes/notes/tasks.md table (| Task | Due | Source | Status |) and surface open/overdue items in the briefs. This file — not the DAV todo store (often broken, above) and not derived memory (drifts) — is authoritative. For time-bound tasks, also schedule a one-off reminder and, if it belongs on the calendar, add a calendar event.

Point the agent’s web tool at a self-hosted SearXNG instance for private, keyless search. Note: SearXNG returns results, not full page text — if you need article extraction, give the agent a dedicated fetch/extract tool and tell it (in SOUL.md) to use that, not a search tool, for “read this URL” requests.

For time-critical pings (a delayed flight, a deadline, a security event) put a self-hosted ntfy server in the stack. The agent publishes internally (http://ntfy/<topic>); your phone subscribes over a public HTTPS URL via the tunnel. Give the agent a dead-simple wrapper so it reliably uses this channel:

Terminal window
# ~/.hermes/bin/alert [--urgent] "message"
prio=high; [ "$1" = "--urgent" ] && { prio=urgent; shift; }
curl -fsS -H "Authorization: Bearer $NTFY_TOKEN" -H "Priority: $prio" \
-d "$1" "$NTFY_URL/$NTFY_TOPIC"

Bind everything to 127.0.0.1/private IPs and front it with a tunnel.

Most agent runtimes load a persona/behavior file (often SOUL.md) fresh on every message. This — not the model, not the skills — is your reliable behavior channel.

Things worth encoding in SOUL.md:

  • “Use your tools — don’t describe.” Without this, agents reply with “here’s how you could do that” instead of doing it. Tell it: run the command first, answer from the result; never reply “I don’t have access” for something it has a tool for.
  • Take initiative (chief-of-staff). Map intent → action: a date → add a calendar event
    • reminder; a receipt → log it to the expense file; a durable fact → write it to the right note page; then reply with what you did.
  • Notes/files override memory for must-be-correct facts (see Memory).
  • The exact API calls for any live data you want it to fetch (your own endpoints, with the auth header referenced as an env var, never the secret inline).
  • One alert channel. Name it explicitly (run the alert command), or the agent will improvise and try to alert you over chat/SMS/email instead.

When the agent drafts a message for you to forward (a note to a colleague, a reply to paste elsewhere), you want the draft in its own clean bubble — separate from the agent’s “want me to send this?” chatter — so you can copy it in one tap. Two problems bite:

  • Chat apps don’t render GitHub-flavored Markdown. Many messengers (SimpleX, for one) use single-character delimiters (*bold*, _italic_, `code`) and have no **bold** or triple-backtick fences — so an agent that wraps a draft in ``` fences just shows literal backticks, which also ruins copy-paste.
  • The adapter sends the whole reply as one message (it only length-chunks), so the draft, the preamble, and the follow-up question all land in one bubble.
  • Run the container as a non-root user; mount config read-only.
  • Secrets server-side only: gitignored .env (chmod 600), referenced as ${VAR} in config. Never commit keys, password hashes, or account tokens. Rotate any shared password the assistant uses.
  • Allowlist who can drive the agent. If it lives in a chat group, restrict commands to known senders rather than “anyone in the group.” For the email gateway, set an explicit allowed-senders list — otherwise anyone who emails the address can task your assistant.
  • no-new-privileges + AppArmor caveats: if you harden the container with no-new-privileges: true, some runtimes that spawn helper processes (Node, envoy) need apparmor: unconfined or they fail silently.
  • Minimise blast radius of the model key — cap/limit the deep-tier key separately.

A model migration, a mail-routing change, or a runtime upgrade on a live assistant is a one-way door if it goes wrong mid-flight — the agent is holding real conversations, real memory, real credentials. Before any of these, in order:

  1. Snapshot the runtime data volume (config, notes, memory, sessions, SimpleX/chat databases) to a timestamped, checksummed archive.
  2. Tag a rollback image of the currently-running container (docker tag hermes hermes:rollback-<timestamp>) before rebuilding.
  3. Copy the pre-change .env alongside the data snapshot — a secrets-rotation step inside the same change is common and easy to lose track of otherwise.
  4. Verify the backup, not just that it ran — checksum it, or restore it to a scratch directory and diff.

This turns “the migration broke something and I have to rebuild from memory” into “revert the tag and restore the snapshot,” which takes minutes instead of hours.

SymptomLikely causeFix
HTTP 400 invalid modelAlias instead of canonical idUse the provider’s exact id (e.g. …-latest)
Agent has no internet / DNS failsContainer on a DOCKER-USER-dropped subnetPin network to an allowed range (e.g. 172.16/12)
Memory service crash-loops on bootMigrations not runRun alembic upgrade head (or equivalent) first
Embeddings 401-loopWrong key for the embeddings moduleGive the embeddings module a real OpenAI key
Agent ignores an installed skillSkills aren’t auto-consultedPut the must-know bits in SOUL.md
Agent recalls wrong trip/flight factsTrusting stale injected memoryNote files override memory; have it cat the file
ntfy publishes but phone is silentNo upstream-base-urlSet it, restart, re-add the phone subscription
ntfy “not authorized”Anonymous / wrong-server subscriptionLog in per-server, re-add subscription
Tunnel DNS create fails (auth error)Token lacks DNS:Edit on the zoneUse cloudflared tunnel route dns … on the host
Mail timeouts from container onlyPublic-IP hairpinextra_hosts pin or join the mail Docker network
Email MCP can’t move/archiveMCP lacks the verbUse a CLI mail client from the agent’s terminal
Calendar reported “unreachable” but server is healthyAgent invented a calendar URLPut the exact collection URL in SOUL.md; verify with PROPFIND (207)
create_todo/VTODO errors at runtimeDAV client doesn’t implement itUse create_event + a Markdown task backlog
Key “works” but real requests failKey authenticates but account has no funded quotaTest with one real completion call, not just an auth probe
Model swap stops calling tools (agent describes instead of doing)New model’s reasoning default incompatible with tool calls on this transportSet effective reasoning_effort: none, or migrate to the Responses-style API
Post-upgrade: runtime crashes, weird half-old/half-new behaviorNo-clobber seed copy can’t upgrade an existing runtimeVersioned atomic promotion (stage → validate → swap, keep old under runtime-rollbacks/)
Same rebuild produces a different runtime each timeInstaller tracks main instead of a pinned releasePin an explicit release tag; fetch the installer from that same tag
Scheduled brief never firescron: config block isn’t the live storeCreate jobs via the runtime CLI (cron/jobs.json); confirm with the job-list cmd
Agent replies to mail it was only CC’d onAdapter doesn’t parse To/CcParse To/Cc + inject role; reply only when in To, [SILENT] otherwise
Draft arrives as one bubble with literal **/```Whole reply sent as one msg; app ≠ GitHub markdownSplit deterministically on fences in send(), strip fences/**