🇬🇧 English · 🇮🇩 Indonesia · 🇲🇾 Malaysia
Hermuse = Hermes + Muse. Scripts and guides to run Hermes Agent (the AI agent from Nous Research) + 9Router + a Telegram gateway on your own server — an exact replica of the stack running on Muse's VM.
End result: your personal Telegram AI bot, online 24/7 and ready to chat anytime, plus a model dashboard you can open from any browser.
What you need:
- A Linux VM like Muse's (Ubuntu 22.04/24.04; 2 vCPU / 4 GB RAM is enough)
- A free Cloudflare account (for the dashboard tunnel)
- A Telegram bot (free via @BotFather)
New to all of this? Follow these in order:
bash scripts/install.sh— installs everything (dependencies, Hermes, 9Router) Verify:hermes --version→ prints a version line;9router --version→ prints a versionhermes setup --portal— model login;hermes gateway setup— connect your Telegram bot Verify: send/startto your bot → it replies (your numeric Telegram ID must be in the gateway allowlist)bash scripts/setup-tunnel.sh <pages-project-name> <d1-name>— install the Cloudflare tunnel Verify:pgrep -f "tunnel-client[.]mjs"→ prints a PID- Follow the correct boot order below, then put the watchdog on cron
Verify:
bash scripts/doctor.sh→ summary shows0 FAIL
Already comfortable and want the operational scripts directly? See the file layout table.
Telegram (you)
│ polling
▼
Hermes gateway ──▶ model:
(run-hermes.sh) ├─▶ Nous (direct, via OAuth / API key)
└─▶ 9Router (127.0.0.1:20128, OpenAI-compatible)
Browser (internet)
│ plain HTTPS
▼
<your-project>.pages.dev (Cloudflare Pages Functions)
│ request/response queue
▼
D1 (tunnel_requests / tunnel_responses tables)
▲ ~20s HTTPS long-poll
│
tunnel-client.mjs (on your VM)
│ forward
▼
9Router at 127.0.0.1:20128
Why long-poll? The /__tunnel/poll endpoint holds the connection for
~20 seconds when the queue is empty before responding (or responds immediately
when a request arrives). Without this, polling every 400ms–2.5s would burn
through Cloudflare Workers' 100k requests/day free-tier quota in hours;
with long-poll, idle usage drops to ~4,300 requests/day. See
tunnel/pages-tunnel/functions/__tunnel/poll.js for the implementation.
9Router binds only to 127.0.0.1 (safe, never exposed). The polling tunnel is
used because on the original VM network, WebSocket, QUIC/UDP, and cloudflared
connections to the edge all failed — see the
failure archive. On a normal VM/network, plain
cloudflared is probably simpler.
| File | Role |
|---|---|
run-hermes.sh |
Runs the Hermes CLI via a provisioned venv (sets HERMES_HOME, minimal no_proxy). Adjust HERMES_VENV / HERMES_SRC at the top of the file. |
start-gateway.sh |
Runs the Telegram gateway detached (setsid+nohup). |
start-9router.sh |
9Router supervisor: binds 127.0.0.1:20128, auto-restarts on crash. Dashboard password is read from .dashboard-pw (600). |
start-pages-tunnel.sh |
Tunnel client supervisor (tunnel-client.mjs), auto-restart. |
restart-9router.sh |
Kills the old 9Router process (safe pattern, anti self-kill) + starts the supervisor detached. |
restart-tunnel.sh |
Kills the old tunnel client + starts the supervisor detached. |
watchdog.sh |
Checks 9Router, tunnel client, gateway; restarts whatever died. For cron. |
gateway-watch.sh |
Anti-stall watchdog for the Telegram gateway: detects silent stalls via event-loop heartbeat + adapter activity, not just "process alive". For cron (every 5 minutes). |
tunnel-client.mjs |
Polling client: pulls the queue from Pages, forwards to local 9Router, sends responses back. |
tunnel/ |
D1 schema (schema.sql) + Pages Functions + wrangler.toml.example. |
scripts/install.sh |
From-scratch install: dependencies, Node.js LTS, Hermes, 9Router. |
scripts/setup-tunnel.sh |
Tunnel setup: generate key → create D1 → deploy Pages → set secret. |
docs/arsip-tunnel-gagal.md |
Archive: cloudflared & Tailscale failures on the original VM network (in Indonesian). |
Run in order (once is enough; supervisors + watchdog handle the rest):
-
9Router first (detached supervisor, auto-restart):
bash restart-9router.shVerify:curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:20128/dashboard→ prints200or307(307 = redirect to login, normal) -
Tunnel client (needs
TUNNEL_BASE_URLor a.tunnel-urlfile):export TUNNEL_BASE_URL='https://<your-project>.pages.dev'thenbash restart-tunnel.shVerify:pgrep -f "tunnel-client[.]mjs"→ prints a PID -
Telegram gateway (detached):
bash start-gateway.shVerify:pgrep -f "[g]ateway['\", ]+run"→ prints exactly one PID
Then open https://<your-project>.pages.dev in a browser — the 9Router
dashboard should appear. If you get 504 tunnel timeout (client offline?),
the tunnel client isn't running yet.
crontab -e
# add this line (adjust the path):
*/5 * * * * /path/to/Hermuse/watchdog.shThe watchdog checks three things — 9Router (curl to /dashboard), the
tunnel client (pgrep tunnel-client[.]mjs), the Telegram gateway
(gateway.*run) — and restarts whatever died via the restart-* scripts.
It stays silent (writes no log) when everything is healthy; it only logs when
it actually restarts something.
For cron: store the tunnel URL in a
.tunnel-urlfile (chmod 600) in this folder, since cron doesn't inherit your interactive environment variables:echo -n 'https://<your-project>.pages.dev' > .tunnel-url && chmod 600 .tunnel-url
The watchdog.sh above only checks "process exists or not". If Telegram
polling stalls while the process is still alive, it slips through.
gateway-watch.sh closes that gap with three layers:
- Gateway PID missing → restart immediately.
- Event-loop heartbeat (
$HERMES_HOME/state/gateway.heartbeat, written automatically by the gateway) stale >120s → restart immediately. - Heartbeat fresh but 0 Telegram adapter activity in
gateway.logfor 15 minutes → mark suspect, restart if confirmed on 2 consecutive runs.
Hard rule: only kill when there is exactly 1 candidate PID (no ambiguity).
Never touches 9Router. Dry-run with no real action:
DRY_RUN=1 bash gateway-watch.sh.
crontab -e
# add (can alternate with watchdog.sh):
*/5 * * * * /path/to/Hermuse/gateway-watch.shscripts/start-all.sh starts any Hermuse component that is down (9Router,
tunnel client, Telegram gateway) — idempotent, running components are untouched:
bash /path/to/Hermuse/scripts/start-all.shTo run it automatically on every VPS reboot, for a normal Linux VPS:
Option 1 — cron @reboot:
crontab -e
# add:
@reboot sleep 30 && /path/to/Hermuse/scripts/start-all.sh >> /path/to/Hermuse/boot.log 2>&1Option 2 — systemd user unit (~/.config/systemd/user/hermuse.service):
[Unit]
Description=Hermuse stack (9Router + tunnel + Telegram gateway)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/path/to/Hermuse/scripts/start-all.sh
RemainAfterExit=yes
[Install]
WantedBy=default.targetsystemctl --user daemon-reload
systemctl --user enable --now hermuse.service
# to run without login: sudo loginctl enable-linger $USER(Both examples are for a normal VPS/network and haven't been tested on every distro — adapt to your system. The 5-minute watchdog cron is still recommended as a safety net.)
Starting with nothing? Begin here:
bash scripts/install.sh # dependencies + Node.js + Hermes + 9Router + hermes doctor(needs passwordless sudo, or run as root — the script fails fast otherwise)
Then the model provider & Telegram gateway wizards:
hermes setup --portal # log in to Nous via OAuth (or: hermes model to pick a provider)
hermes gateway setup # enter your Telegram bot token + numeric-ID allowlistFinally, the Cloudflare tunnel setup:
CLOUDFLARE_API_TOKEN='<token>' bash scripts/setup-tunnel.sh <pages-project-name> <d1-name>(The Cloudflare token is used transiently — it is not stored in any file.)
- Secret files are always
chmod 600and never committed:.env,.tunnel-key,.tunnel-url,.dashboard-pw,wrangler.toml(all covered by.gitignore). - Telegram gateway: default-deny. Only numeric IDs in
TELEGRAM_ALLOWED_USERScan use the bot (TELEGRAM_BOT_TOKEN+TELEGRAM_ALLOWED_USERSlive in.env). - Change the 9Router dashboard password from the default right after install — especially since the tunnel URL is public.
- Don't expose port 20128 to the internet unprotected; 9Router binds to
127.0.0.1only. - If your Telegram bot token leaks:
/revokein @BotFather, replace it in.env, restart the gateway.
What failed on the original network (archive)
On the original VM network, two standard approaches failed completely:
- Cloudflare Tunnel (
cloudflared) — QUIC/UDP blocked, TLS to edge IPs intercepted, proxy refused CONNECT to port 7844. - Tailscale — control plane couldn't get through the network (HTTP 400 from MITM).
Details + sanitized config examples: docs/arsip-tunnel-gagal.md (in Indonesian). On a normal VM/network both are probably the easiest route.
MIT.
