Three-panel dark web UI for Hermes Agent. Full CLI parity in browser.
# Clone
git clone https://github.com/nesquena/hermes-webui.git /opt/hermes-webui
Dependencies
apt-get install -y python3-venv # needed for bootstrap.py's venv creation
Run with password auth (port 8788 internal)
cd /opt/hermes-webui
HERMES_WEBUI_PASSWORD=munaf \
HERMES_WEBUI_PORT=8788 \
HERMES_HOME=/root/.hermes \
HERMES_WEBUI_PYTHON=/usr/local/lib/hermes-agent/venv/bin/python3 \
python3 bootstrap.py --host 127.0.0.1 --no-browser --foreground --skip-agent-install 8788
Key env vars:
HERMES_WEBUI_PASSWORD — enables cookie-based auth login page (built-in, no nginx basic auth needed)HERMES_WEBUI_PORT — default 8787, override with env var or positional argHERMES_HOME — must point to agent's ~/.hermes for sessions/configHERMES_WEBUI_PYTHON — MUST point to hermes-agent venv python (/usr/local/lib/hermes-agent/venv/bin/python3) or bootstrap fails with "cannot import both WebUI and Hermes Agent"HERMES_WEBUI_AGENT_DIR — MUST point to hermes-agent installation (/usr/local/lib/hermes-agent) or the WebUI shows [XX] Could not find the Hermes agent directory and agent features (chat, tools) won't workBootstrap creates .venv in the repo directory on first run. If it fails:
rm -rf /opt/hermes-webui/.venv # clean failed venv
apt-get install -y python3-venv # required for venv creation
[Unit]
Description=Hermes Web UI
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/hermes-webui
Environment=HERMES_WEBUI_PASSWORD=munaf
Environment=HERMES_WEBUI_PORT=8788
Environment=HERMES_HOME=/root/.hermes
Environment=HERMES_WEBUI_PYTHON=/usr/local/lib/hermes-agent/venv/bin/python3
Environment=HERMES_WEBUI_AGENT_DIR=/usr/local/lib/hermes-agent
ExecStart=/usr/local/lib/hermes-agent/venv/bin/python3 /opt/hermes-webui/bootstrap.py --host 127.0.0.1 --no-browser --foreground --skip-agent-install 8788
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Save to /etc/systemd/system/hermes-webui.service, then:
systemctl daemon-reload
systemctl enable --now hermes-webui
The WebUI already has built-in password auth (cookie-based, login page). Do NOT add nginx auth_basic on top — it creates a double-login. Just proxy pass:
server {
server_name webui.example.com;
location / {
proxy_pass http://127.0.0.1:8788;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
}
Then SSL: certbot --nginx -d webui.example.com
The WebUI must bind to 127.0.0.1 (not 0.0.0.0) so nginx is the only external path.
| Method | Pros | Cons |
|--------|------|------|
| HERMES_WEBUI_PASSWORD env var | Built-in, cookie sessions, proper login page | Single shared password |
| Nginx auth_basic + .htpasswd | Standard, per-user | Avoid — creates double login with WebUI's own auth |
| Combined: WebUI auth only | Clean UX, works well | Single password (sufficient for personal use) |
curl http://127.0.0.1:8788/api/auth/status
{"auth_enabled": true, "logged_in": false}
Login
curl -X POST http://127.0.0.1:8788/api/auth/login \
-H "Content-Type: application/json" \
-d '{"password":"munaf"}'
{"ok": true}
When deploying to a fresh server, follow this sequence:
# 1. Install system packages (Ubuntu 24.04+)
apt-get update -qq && apt-get install -y python3 python3-pip python3-venv nginx certbot python3-certbot-nginx git curl ripgrep
2. Install hermes-agent via git clone (preferred over pip)
git clone https://github.com/nousresearch/hermes-agent.git /usr/local/lib/hermes-agent
cd /usr/local/lib/hermes-agent
bash setup-hermes.sh # creates venv, installs deps, creates wrapper
3. Create CLI symlink (setup-hermes.sh may not create it)
cat > /usr/local/bin/hermes << 'EOF'
#!/usr/bin/env bash
unset PYTHONPATH
unset PYTHONHOME
exec "/usr/local/lib/hermes-agent/venv/bin/hermes" "$@"
EOF
chmod +x /usr/local/bin/hermes
4. Clone and configure WebUI (see Quick Install above)
5. Copy config and skills from existing server:
On SOURCE: tar czf /tmp/hermes-skills.tar.gz -C /root/.hermes skills
scp /root/.hermes/config.yaml root@NEWHOST:/root/.hermes/
scp /tmp/hermes-skills.tar.gz root@NEWHOST:/tmp/
On TARGET: cd /root/.hermes && tar xzf /tmp/hermes-skills.tar.gz
6. Set up systemd service (see above)
7. Set up nginx + SSL (see above)
8. Restart: systemctl restart hermes-webui
CRITICAL: Always copy config.yaml via scp (raw file transfer), NOT via Python yaml.safe_load() → yaml.dump(). PyYAML's dump() mangles multiline strings, loses flow-style API keys, and reorders structures. If you must programmatically edit config, use sed or Python string replacement on the raw file — never round-trip through yaml.dump().
After copying config from another server, the provider: opencode-go default may fail with 401 because it requires an env var (OPENCODE_GO_API_KEY) not present on the new server. Fix options:
sed -i 's/default: glm-5/default: glm-5-turbo/' /root/.hermes/config.yaml
sed -i 's/provider: opencode-go/provider: chutes/' /root/.hermes/config.yaml
systemctl restart hermes-webui
Environment=OPENCODE_GO_API_KEY=sk-your-key-here
Always test auth after deploy: hermes chat -q "hello" on the remote server. If it returns 401, switch the default provider before declaring success.
openclaw-install skill for OpenClaw deployment.hermes chat -q "query" for scripted one-shot queries — the TUI produces garbled output. Use llm-ask CLI tool instead.python3-venv package must be installed first, or .venv creation fails. Delete .venv and retry after installing it./usr/local/lib/hermes-agent). Without it, the WebUI starts but shows [XX] Could not find the Hermes agent directory and agent features (chat, tool calls) return errors.pip install hermes-agent fails with "externally-managed-environment". Use git clone + setup-hermes.sh instead (creates proper venv). If you must use pip: pip3 install --break-system-packages --ignore-installed pyyaml hermes-agent (the --ignore-installed is needed because the system PyYAML 6.0.1 has a missing RECORD file that blocks upgrades).ssh-copy-id fails with mktemp errors. Manually push the key instead: sshpass -p 'PW' ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no root@HOST "mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo 'PUBLIC_KEY_CONTENT' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"opencode-go provider needs OPENCODE_GO_API_KEY env var. After copying config to a new server, this key isn't set. Either add the env var or switch the default model/provider to one with an inline API key (e.g., chutes/glm-5-turbo).libgtk-3.so.0: cannot open shared object file. The CamoFox HTTP API still responds to /health but tab creation returns 500. Fix: apt-get install -y libgtk-3-0 libdbus-glib-1-2 libxt6 libasound2t64 libatk1.0-0 libatk-bridge2.0-0 libcups2 libxdamage1 libxrandr2 libxcomposite1 libxfixes3 libx11-xcb1 libxcb1 libxcursor1 libxi6 libpango-1.0-0 libpangocairo-1.0-0 libcairo2 libcairo-gobject2 libgdk-pixbuf-2.0-0 libfreetype6 libfontconfig1 libatk-adaptor libatspi2.0-0 xvfb. Then systemctl restart camofox.sessionKey — the POST /tabs endpoint returns {"error":"userId and sessionKey required"} if sessionKey is missing. Always include both: {"userId":"user1","sessionKey":"default","url":"https://example.com"}..env are masked — the .hermes/.env file stores keys like OPENCODE_GO_API_KEY=* (masked by hermes). The real keys are in config.yaml under each provider's api_key: field. When copying config to a new server, scp config.yaml is sufficient for all providers that have inline keys. Only the opencode-go default provider needs an env var.pip3 install on Ubuntu 24.04 requires --ignore-installed for PyYAML — system PyYAML 6.0.1 has a missing RECORD file that blocks upgrades. Use pip3 install --break-system-packages --ignore-installed pyyaml hermes-agent._git_clone_plus_setup-hermes.sh_method_is_preferred_though.Protect the WebUI from brute-force login attempts and API abuse with nginx rate limiting:
# In http {} block (not inside server {})
limit_req_zone $binary_remote_addr zone=webui_login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=webui_api:10m rate=30r/m;
server {
server_name webui.example.com;
# Rate-limit the login endpoint specifically
location /api/auth/login {
limit_req zone=webui_login burst=5 nodelay;
proxy_pass http://127.0.0.1:8788;
}
# Rate-limit all other API calls
location /api/ {
limit_req zone=webui_api burst=20 nodelay;
proxy_pass http://127.0.0.1:8788;
}
location / {
proxy_pass http://127.0.0.1:8788;
# ... existing proxy headers ...
}
}
— 5 login attempts per minute per IP, burst of 5 — 30 API calls per minute per IPPrevent systemd from permanently disabling the service after repeated crashes (e.g., during debugging):
[Service]
... existing config ...
StartLimitBurst=10
StartLimitIntervalSec=300
If the service still gets stuck in failed state after exhausting retries:
systemctl reset-failed hermes-webui
Without this, systemd marks the unit as failed after 5 rapid restarts within the default interval and refuses to restart it, even with Restart=always.
Always test certbot with --dry-run before requesting a real certificate. Let's Encrypt has strict rate limits (5 certificates per domain per week):
certbot --nginx -d webui.example.com --dry-run
Only run the real command after dry-run succeeds:
certbot --nginx -d webui.example.com
Configure systemd to notify you when the WebUI service enters a failed state:
[Unit]
OnFailure=hermes-webui-notify.service
[Service]
... existing config ...
Then create /etc/systemd/system/hermes-webui-notify.service:
[Unit]
Description=Notify on WebUI failure
[Service]
Type=oneshot
ExecStart=/usr/local/bin/hermes-webui-notify.sh
The notify script can send an alert via Telegram, email, or any channel you monitor. Alternatively, use FailureAction= with journalctl notifications.
Harden the nginx proxy with standard security headers:
server {
# ... existing config ...
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss:; img-src 'self' data:;" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
— prevents the WebUI from being embedded in iframes on other domains — restricts scripts/styles to same-origin (with unsafe-inline/unsafe-eval for the TUI) — forces HTTPS for 1 year after first visit — allows WebSocket connections), active WebSocket connections to the WebUI are dropped. To minimize disruption, schedule renewals during low-traffic windows: systemctl edit certbot.timer and set OnCalendar=--* 04:00:00. Users will see a brief reconnection splash, not a hard failure. in /opt/hermes-webui/.venv becomes broken because the symlinked python binary points to the old version. Symptom: ImportError: No module named _hashlib or similar. Fix: delete .venv and re-run bootstrap: rm -rf /opt/hermes-webui/.venv
cd /opt/hermes-webui && python3 bootstrap.py --host 127.0.0.1 --no-browser --foreground --skip-agent-install 8788
The re-create only takes a few minutes. Pin the venv to the hermes-agent Python (HERMES_WEBUI_PYTHON=/usr/local/lib/hermes-agent/venv/bin/python3) to avoid this entirely — if that venv is maintained by the agent install, it won't go stale on a system upgrade.
Caddy is a simpler alternative to nginx for the reverse proxy layer. It provides automatic HTTPS via Let's Encrypt with zero configuration — no certbot cron jobs, no manual certificate renewal, no nginx config editing.
webui.example.com {
reverse_proxy 127.0.0.1:8788
# Security headers
header X-Frame-Options "SAMEORIGIN"
header X-Content-Type-Options "nosniff"
header Referrer-Policy "strict-origin-when-cross-origin"
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
# Rate limiting (login endpoint)
handle /api/auth/login {
rate_limit {remote.host} 5r/m
reverse_proxy 127.0.0.1:8788
}
}
# Install Caddy
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt-get update && apt-get install -y caddy
Copy Caddyfile
cp Caddyfile /etc/caddy/Caddyfile
Start Caddy
systemctl enable --now caddy
Caddy automatically provisions and renews TLS certificates for the domain in the Caddyfile. It handles HTTP→HTTPS redirects, OCSP stapling, and certificate renewal on its own — no certbot renew cron needed.
When to choose Caddy over nginx:
When to stay with nginx:
directives)Let's Encrypt announced they are discontinuing their expiration notification emails starting June 2025. After this date, you will no longer receive automated warnings when certificates are about to expire. This means:
and --post-hook scripts become your primary safety netSet up monitoring that alerts you before certificates expire — even if automatic renewal is working, you need confidence that renewal actually succeeded:
#!/usr/local/bin/check-cert-expiry.sh
Check all Let's Encrypt certs and alert if any expire within 14 days
ALERT_DAYS=14
for cert in /etc/letsencrypt/live/*/cert.pem; do
domain=$(echo "$cert" | cut -d'/' -f5)
expiry=$(openssl x509 -enddate -noout -in "$cert" | cut -d= -f2)
expiry_epoch=$(date -d "$expiry" +%s)
now_epoch=$(date +%s)
days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
if [ "$days_left" -lt "$ALERT_DAYS" ]; then
echo "WARNING: Certificate for $domain expires in $days_left days ($expiry)" >&2
# Send alert via your preferred channel:
# curl -s "https://api.telegram.org/bot$TOKEN/sendMessage?chat_id=$CHAT_ID&text=Cert+for+$domain+expires+in+$days_left+days"
fi
done
# Run daily via cron
/etc/cron.d/cert-expiry-check
0 8 * root /usr/local/bin/check-cert-expiry.sh
ACME Renewal Information (ARI) is a new Let's Encrypt feature that tells your ACME client the optimal renewal window for each certificate. Instead of renewing on a fixed schedule, certbot with ARI checks when the CA recommends renewal and schedules accordingly, reducing load on both sides and avoiding renewal during CA maintenance windows.
Requirements:
)# Install or upgrade certbot
apt-get install -y certbot python3-certbot-nginx
Or with pip for latest version:
pip3 install --break-system-packages certbot certbot-nginx
Verify ARI support
certbot certificates 2>&1 | grep -i "ari\|renewal"
With certbot 2.8+, ARI is used automatically during certbot renew. The renewal schedule shifts from "renew if <30 days left" to "renew when the CA says it's optimal," which:
Set up a certbot deploy-hook for notifications (replaces the old email alerts):
certbot renew --deploy-hook "/usr/local/bin/notify-cert-renewal.sh"
#!/usr/local/bin/notify-cert-renewal.sh
Called by certbot after successful renewal
$RENEWED_DOMAINS and $RENEWED_LINEAGE are set by certbot
for domain in $RENEWED_DOMAINS; do
echo "Certificate renewed successfully for $domain at $(date)" >> /var/log/certbot-renewals.log
# Optional: send notification
# curl -s "https://api.telegram.org/bot$TOKEN/sendMessage?chat_id=$CHAT_ID&text=Cert+for+$domain+renewed+successfully"
done
Caddy users: Caddy already handles ARI-aware renewal natively — no additional configuration needed. This is one of the key advantages of Caddy over nginx+certbot for single-domain HTTPS setups.
— repo directory — systemd unit — nginx config — one-shot LLM query CLI (14 providers) — agent config (providers, models, API keys) — skill library (tarball transfer from source server)