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.
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
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/**