IMPORTANT — Agent Instructions:
- Execute all SSH commands in the backend / sidebar / headless mode, not in the frontend chat UI. Do not display each command to the user — just run them and report progress. - The user only does ONE thing: double-click the CMD file in Phase 2.
- Do NOT ask user for SSH credentials in chat. Those go into the CMD prompts only.
- Every link you send to user must be a clickable URL.
PHASE CHECKPOINTS: After completing each phase, print
[CHECKPOINT Phase N complete]
. If interrupted, re-read gist, skip completed phases, resume from failure.IDEMPOTENT COMMANDS: Every SSH command must be safe to re-run. Use-qq
, overwrite configs, DELETE before INSERT on DB.
For Hermes Desktop, Claude Code, Cline, or any AI agent. Read this gist and execute every phase.
Ask user for these in chat. Use the exact message format below.
Your message to user:
"Here's what I need from you to proceed:
1. Tailscale on your computer — Have you already installed Tailscale and logged in? If not, download and install it from that link, log in with any Google/Microsoft/email account, then tell me when you're done.
2. OpenModel API key — Go to OpenModel, sign up, and create an API key. Paste it here.
3. Domains (optional) — Only if you already have domains pointing to your server's IP:
- Dashboard domain (e.g. hermes.example.com)
- Router domain (e.g. router.example.com)
If you don't have domains, skip this — I'll use Tailscale IP instead.
Once you provide those, I'll:
- Generate the exact hermes-onboard.cmd file
- Walk you through saving and running it (just one double-click)
- Then handle everything else in the background"
Required from user response:
- "Yes" or "No" on Tailscale local install
- OpenModel API key (starts with
om-
) - Domains (optional)
Wait for user to respond. If they say Tailscale not installed, tell them: "Download it from https://tailscale.com/download — it's free, takes 2 minutes."
Generate this exact CMD file. Write it directly to the user's desktop using your file system tools, then tell them with a clickable link:
Your message to user:
"I've created hermes-onboard.cmd on your desktop. Double-click it and enter your server details when prompted. Let me know when it says 'ONBOARDING COMPLETE'."
If your platform cannot create clickable file links, tell them the exact path instead: C:\Users\YOUR_USERNAME\Desktop\hermes-onboard.cmd
.
Write the file to %USERPROFILE%\Desktop\hermes-onboard.cmd
(or the equivalent desktop path for the user's OS).
@echo off
echo ============================================================
echo Hermes Remote Server Onboarding
echo ============================================================
echo.
REM --- All inputs hidden via PowerShell, saved to .txt ---
REM (handles ANY special character - no batch parsing issues)
echo Server IP or hostname:
powershell -NoProfile -Command "$s=Read-Host -AsSecureString;$b=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s);$t=[Runtime.InteropServices.Marshal]::PtrToStringBSTR($b);[System.IO.File]::WriteAllText([System.IO.Path]::GetTempPath()+'h_H.txt',$t)"
set /p HOST=<"%TEMP%\h_H.txt"
del "%TEMP%\h_H.txt" 2>nul
if "%HOST%"=="" goto :eof
echo SSH port [22]:
powershell -NoProfile -Command "$s=Read-Host -AsSecureString;$b=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s);$t=[Runtime.InteropServices.Marshal]::PtrToStringBSTR($b);if($t-eq''){$t='22'};[System.IO.File]::WriteAllText([System.IO.Path]::GetTempPath()+'h_P.txt',$t)"
set /p PORT=<"%TEMP%\h_P.txt"
del "%TEMP%\h_P.txt" 2>nul
echo SSH username [root]:
powershell -NoProfile -Command "$s=Read-Host -AsSecureString;$b=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s);$t=[Runtime.InteropServices.Marshal]::PtrToStringBSTR($b);if($t-eq''){$t='root'};[System.IO.File]::WriteAllText([System.IO.Path]::GetTempPath()+'h_U.txt',$t)"
set /p USER=<"%TEMP%\h_U.txt"
del "%TEMP%\h_U.txt" 2>nul
echo SSH password:
powershell -NoProfile -Command "$s=Read-Host -AsSecureString;$b=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s);$t=[Runtime.InteropServices.Marshal]::PtrToStringBSTR($b);[System.IO.File]::WriteAllText([System.IO.Path]::GetTempPath()+'h_Sp.txt',$t)"
set /p SSH_PASS=<"%TEMP%\h_Sp.txt"
del "%TEMP%\h_Sp.txt" 2>nul
if not defined SSH_PASS (
echo [WARN] Hidden input failed, visible fallback.
set /p SSH_PASS=SSH password:
)
echo Sudo password:
powershell -NoProfile -Command "$s=Read-Host -AsSecureString;$b=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s);$t=[Runtime.InteropServices.Marshal]::PtrToStringBSTR($b);[System.IO.File]::WriteAllText([System.IO.Path]::GetTempPath()+'h_Su.txt',$t)"
set /p SUDO_PASS=<"%TEMP%\h_Su.txt"
del "%TEMP%\h_Su.txt" 2>nul
if not defined SUDO_PASS (
echo [WARN] Hidden input failed, visible fallback.
set /p SUDO_PASS=Sudo password:
)
set "KEY=%USERPROFILE%\.ssh\hermes_remote"
set "CONFIG=%USERPROFILE%\.ssh\config"
set "LABEL=remote-hermes"
echo.
echo [STEP 1] Cleaning local SSH state ...
if exist "%KEY%" del /f "%KEY%" 2>nul
if exist "%KEY%.pub" del /f "%KEY%.pub" 2>nul
if exist "%CONFIG%" del /f "%CONFIG%" 2>nul
if exist "%USERPROFILE%\.ssh\known_hosts" del /f "%USERPROFILE%\.ssh\known_hosts" 2>nul
if not exist "%USERPROFILE%\.ssh" mkdir "%USERPROFILE%\.ssh"
echo Done.
echo [STEP 2] Generating new SSH key ...
ssh-keygen -t ed25519 -f "%KEY%" -N "" -q
echo Done.
echo [STEP 3] Configuring SSH host alias: %LABEL%
>>"%CONFIG%" echo Host %LABEL%
>>"%CONFIG%" echo HostName %HOST%
>>"%CONFIG%" echo User %USER%
>>"%CONFIG%" echo Port %PORT%
>>"%CONFIG%" echo IdentityFile %KEY%
>>"%CONFIG%" echo StrictHostKeyChecking no
echo Done.
echo [STEP 4] Pushing public key to remote ...
echo You will be prompted for your SSH password ONCE.
echo.
type "%KEY%.pub" | ssh -p %PORT% %USER%@%HOST% "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
if %ERRORLEVEL% NEQ 0 (
echo [FAILED] Key push failed. Check your SSH password and try again.
exit /b 1
)
echo Done.
echo [STEP 5] Enabling passwordless sudo ...
echo Opening interactive session. Enter your SUDO password ONCE.
echo.
ssh -t -p %PORT% %USER%@%HOST% "echo '%USER% ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/%USER%-nopasswd >/dev/null && sudo chmod 440 /etc/sudoers.d/%USER%-nopasswd && sudo visudo -cf /etc/sudoers.d/%USER%-nopasswd && echo SUDO_OK"
if %ERRORLEVEL% NEQ 0 (
echo [FAILED] Sudo setup failed. Check your sudo password and try again.
exit /b 1
)
echo [STEP 6] Verifying seamless access ...
ssh -p %PORT% %LABEL% "sudo -n true && echo SUDO_OK || echo SUDO_FAIL"
echo.
echo ============================================================
echo ONBOARDING COMPLETE
echo Future access: ssh %LABEL%
echo ============================================================
Then print: [CHECKPOINT Phase 1 complete]
Do NOT run the install command below directly. First read the official installation docs at https://hermes-agent.nousresearch.com/docs/getting-started/installation and follow the exact instructions there. The docs may have changed — use them as source of truth.
ssh remote-hermes "curl -fsSL https://hermes-agent.nousresearch.com/install | bash"
Fix ui-tui (prevents dashboard Chat crash code 1011):
ssh remote-hermes "ln -sf ~/hermes-venv/lib/python3.12/site-packages/hermes_cli/tui_dist ~/hermes-venv/lib/python3.12/site-packages/ui-tui"
The web dashboard needs FastAPI + Uvicorn (web extra) and ptyprocess (pty extra for embedded Chat tab). Install both:
ssh remote-hermes "export PATH=~/hermes-venv/bin:\$PATH && cd ~/hermes-venv && uv pip install -e '.[web,pty]'"
Or install everything at once (messaging, voice, etc.):
ssh remote-hermes "export PATH=~/hermes-venv/bin:\$PATH && cd ~/hermes-venv && uv pip install -e '.[all]'"
The web dashboard is the browser-based management UI. Full docs at https://hermes-agent.nousresearch.com/docs/user-guide/features/web-dashboard
Pages available after launch:
| Page | What it does |
|---|---|
| Status | |
| Live overview: agent version, gateway state, active sessions, recent sessions (auto-refresh 5s) | |
| Chat | |
Full embedded TUI in browser — same as hermes --tui . Keyboard, model picker, session resume, markdown, tool-call cards. Requires ptyprocess (installed above) |
|
| Config | |
| Form-based editor for all 150+ config.yaml fields, tabbed by category | |
| API Keys | |
| Manage .env secrets: LLM providers, tool APIs, messaging tokens. Redacted previews | |
| Sessions | |
| Browse/search all sessions with FTS5. Expand messages, rename, export, delete, prune old | |
| Logs | |
| View agent/gateway/error logs with level/component filter + live tail | |
| Analytics | |
| Token usage and cost charts (7/30/90 day windows), per-model breakdown | |
| Cron | |
| Create/edit//resume cron jobs from browser | |
| Profiles | |
| Create isolated Hermes profiles with own config, skills, sessions | |
| Skills | |
| Browse, enable/disable, install from hub | |
| MCP | |
| Add/test/enable/disable MCP servers (HTTP/stdio), browse catalog | |
| Webhooks | |
| Manage dynamic webhook subscriptions | |
| Pairing | |
| Approve/revoke messaging users | |
| Channels | |
| Connect Telegram/Discord/Slack/WhatsApp/etc. from the browser | |
| System | |
| Host stats, gateway start/stop, memory reset, backup/restore, shell hooks, doctor |
Auth for remote access: By default dashboard binds 127.0.0.1
(localhost only). When exposing remotely (Phase 4), you must either:
- Put it behind Caddy + Tailscale (recommended — no auth needed on tailnet)
- Or set up username/password auth via
HERMES_DASHBOARD_BASIC_AUTH_USERNAME
+PASSWORD
in.env
Desktop remote connect: User's Hermes Desktop can attach to this remote dashboard via Settings → Gateway → Remote gateway. URL will be the Tailscale IP or domain from Phase 4.
Start dashboard:
ssh remote-hermes "export PATH=~/hermes-venv/bin:\$PATH && nohup hermes dashboard &>/tmp/hermes.log & sleep 6"
Verify:
ssh remote-hermes "ss -tlnp | grep 9119"
ssh remote-hermes "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9119/"
Must return 200
.
Then print: [CHECKPOINT Phase 2 complete]
ssh remote-hermes "sudo apt-get update -qq && sudo apt-get install -y -qq nodejs npm && sudo npm install -g 9router"
Start 9router:
ssh remote-hermes "tmux new-session -d -s 9router 'cd /usr/local/lib/node_modules/9router && PORT=20128 node app/server.js' && sleep 5"
Verify port:
ssh remote-hermes "ss -tlnp | grep 20128"
Generate API key:
ssh remote-hermes 'python3 << '\''PYEOF'\''
import sqlite3, os, uuid
db = os.path.expanduser("~/.9router/db/data.sqlite")
conn = sqlite3.connect(db)
kid = uuid.uuid4().hex[:8]
key = "sk-" + uuid.uuid4().hex
conn.execute("INSERT INTO apiKeys(id, key, name, machineId, isActive, createdAt) VALUES(?, ?, ?, ?, 1, datetime('now'))", (kid, key, "hermes-agent", ""))
conn.commit()
print(key)
conn.close()
PYEOF'
Capture output as 9ROUTER_KEY
.
Disable login:
ssh remote-hermes 'python3 << '\''PYEOF'\''
import sqlite3, json, os
conn = sqlite3.connect(os.path.expanduser("~/.9router/db/data.sqlite"))
data = json.dumps({"requireLogin": False, "tunnelDashboardAccess": False, "enableObservability": True})
conn.execute("INSERT OR REPLACE INTO settings(id, data) VALUES(1, ?)", (data,))
conn.commit()
print("ok")
PYEOF'
Add OpenModel provider via direct DB insert (sets correct base URL https://api.openmodel.ai
):
ssh remote-hermes 'python3 << '\''PYEOF'\''
import sqlite3, os, json, uuid
db = os.path.expanduser("~/.9router/db/data.sqlite")
conn = sqlite3.connect(db)
cur = conn.cursor()
pid = str(uuid.uuid4())
data = json.dumps({
"apiKey": "OPENMODEL_KEY",
"baseUrl": "https://api.openmodel.ai",
"testStatus": "unknown",
"providerSpecificData": {"connectionProxyEnabled": false, "connectionProxyUrl": "", "connectionNoProxy": ""}
})
cur.execute("DELETE FROM providerConnections WHERE name = 'OpenModel'")
cur.execute("INSERT INTO providerConnections(id, provider, authType, name, priority, isActive, data, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
(pid, "openai", "apikey", "OpenModel", 1, 1, data, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"))
conn.commit()
print("OpenModel added with custom base URL")
conn.close()
PYEOF'
Replace OPENMODEL_KEY with user's actual key.
Then print: [CHECKPOINT Phase 3 part 1 complete]
Write a helper script on the server so escaping is clean:
ssh remote-hermes "export PATH=~/hermes-venv/bin:\$PATH && python3 << 'PYEOF'
import json, subprocess, os
key = '9ROUTER_KEY' # REPLACE with actual captured key above
providers = json.dumps([{'name': '9router', 'base_url': 'http://localhost:20128/v1', 'api_key': key}])
subprocess.run(['hermes', 'config', 'set', 'custom_providers', providers], check=True)
subprocess.run(['hermes', 'config', 'set', 'model.provider', 'custom:9router'], check=True)
print('Hermes config updated')
PYEOF"
Run these checks. If any fail, do NOT proceed — the next restart will silently fail with 401s.
ssh remote-hermes "export PATH=~/hermes-venv/bin:\$PATH && hermes config show"
Must show model.provider = custom:9router
and custom_providers
with 9router entry.
If it still shows nousportal
, openrouter
, or anything else, Hermes will try to reach portal.nousresearch.com
for every API call and hit 401.
ssh remote-hermes "curl -s http://localhost:20128/v1/models -H 'Authorization: Bearer ***' | python3 -m json.tool | head -20"
Restart Hermes dashboard (picks up new config):
ssh remote-hermes "export PATH=~/hermes-venv/bin:\$PATH && pkill -f 'hermes dashboard' && nohup hermes dashboard &>/tmp/hermes.log & sleep 6"
Then print: [CHECKPOINT Phase 3 complete]
ssh remote-hermes "sudo apt-get install -y -qq caddy"
ssh remote-hermes "curl -fsSL https://tailscale.com/install.sh | sh && sudo systemctl start tailscaled && sudo tailscale up --accept-routes --accept-dns=false"
This prints a URL. Send the clickable link to user: "Open this link in your browser to connect this server to your Tailscale: [TAILSCALE_LOGIN_URL]". Wait for them to confirm they logged in.
ssh remote-hermes "tailscale ip -4"
Save as TS_IP
.
If user provided domains AND DNS is set up:
ssh remote-hermes 'sudo tee /etc/caddy/Caddyfile > /dev/null << '\''EOF'\''
{
admin off
}
DASHBOARD_DOMAIN {
reverse_proxy 127.0.0.1:9119 {
header_up Host localhost:9119
header_up Origin http://localhost:9119
flush_interval -1
transport http {
versions 1.1
read_timeout 0
write_timeout 0
}
}
}
ROUTER_DOMAIN {
reverse_proxy 127.0.0.1:20128
}
EOF'
If no domains — use Tailscale IP only:
ssh remote-hermes 'sudo tee /etc/caddy/Caddyfile > /dev/null << '\''EOF'\''
{
admin off
}
:80 {
reverse_proxy 127.0.0.1:9119 {
header_up Host localhost:9119
header_up Origin http://localhost:9119
flush_interval -1
transport http {
versions 1.1
read_timeout 0
write_timeout 0
}
}
}
EOF'
Reload:
ssh remote-hermes "sudo caddy fmt --overwrite /etc/caddy/Caddyfile && sudo systemctl reload caddy"
ssh remote-hermes "sudo ufw default deny incoming && sudo ufw allow 22/tcp && sudo ufw allow in on tailscale0 && sudo ufw --force enable"
ssh remote-hermes "sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config && sudo sed -i 's/^#*ChallengeResponseAuthentication.*/ChallengeResponseAuthentication no/' /etc/ssh/sshd_config && sudo systemctl restart sshd"
ssh remote-hermes "sudo apt-get install -y -qq fail2ban && sudo systemctl enable --now fail2ban"
If domains:
curl -s -o /dev/null -w "%{http_code}" https://DASHBOARD_DOMAIN/
Then print: [CHECKPOINT Phase 4 complete]
Send user a clickable link to their dashboard:
If domains: "Your Hermes dashboard is ready at https://DASHBOARD_DOMAIN/ and your AI gateway at https://ROUTER_DOMAIN/."
If Tailscale only: "Your Hermes dashboard is ready at http://TS_IP/. Make sure you're connected to Tailscale."
Also tell them: "You can SSH in anytime with ssh remote-hermes
."
- SSH key-only authentication (password login disabled)
- UFW firewall (SSH + tailscale0 only)
- fail2ban running (blocks brute-force SSH attempts)
Ask user if they want automatic security updates:
ssh remote-hermes "sudo apt-get install -y -qq unattended-upgrades && sudo dpkg-reconfigure -f noninteractive unattended-upgrades"
| Problem | Cause | Fix |
|---|---|---|
| Dashboard 400 / blank | Host header rejected | Caddy header_up Host localhost:9119 |
| WS code 1006 | WS headers stripped | Also add header_up Origin http://localhost:9119 |
| Chat crash 1011 | ui-tui missing | ln -sf .../tui_dist .../ui-tui |
| Auth forced | --host 0.0.0.0 |
|
| Bind 127.0.0.1 (default), no basic_auth | ||
| 9router 401 | API key missing | Re-run SQLite insert |
| 401 on any model call | ||
| model.provider still pointing at Nous Portal | ||
Run hermes config show — must show model.provider = custom:9router . Also check custom_providers has 9router entry with correct API key. A stale nousportal provider causes every API call to hit portal.nousresearch.com and get rejected. |
||
| 502 | Service dead | `ss -tlnp |
| Models not working | Wrong base URL | OpenModel must use https://api.openmodel.ai |
| Can't reach dashboard | Not on Tailscale | Install Tailscale locally, same account |