- New to Hermes? Head over to the How to Setup section for a step-by-step guide on getting started.
-
-
-
-
- LLM Provider
- * required
-
-
-
- The dropdown below has the most-used providers. Hermes adds new ones
- often and this list won't always be perfectly in sync — but every
- provider Hermes supports is configurable from the
- Hermes Dashboard
- → Keys tab. A typical flow: pick one here (e.g.
- OpenRouter) to get the agent running, then open the dashboard to add
- or switch to any other provider you need.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
OpenRouter format: provider/model-name
-
-
-
-
-
- Messaging Channels
- — at least one required
-
-
-
-
-
-
- ✈️
- Telegram
-
-
-
-
-
Use * to allow all users
-
-
-
-
-
-
- 🎮
- Discord
-
-
-
-
-
Use * to allow all users
-
-
-
-
-
-
- 💼
- Slack
-
-
-
-
-
-
-
-
-
-
-
- 📧
- Email
-
-
-
-
-
-
-
-
-
-
-
-
-
- 🔷
- Mattermost
-
-
-
-
-
-
-
-
-
-
-
- 🔢
- Matrix
-
-
-
-
-
-
-
-
-
-
-
-
- 💬
- WhatsApp
- pairing via gateway logs on first run
-
-
-
-
-
-
-
-
-
Allow all users
-
Skip per-platform allowlists — use with caution
-
-
-
-
-
- Tool API Keys
- — optional
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Status
-
-
-
-
-
-
-
Gateway State
-
-
-
-
Uptime
-
-
-
-
Pending Pairs
-
-
-
-
Model
-
-
-
-
-
-
-
-
-
-
-
-
-
Providers
-
-
-
-
-
-
-
-
-
-
-
Channels
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Logs
-
-
-
-
-
-
-
No logs yet.
-
-
-
-
-
-
-
Users
-
Pairing requests & approved
-
-
-
-
Pending Pairing Requests
-
No pending requests. Unauthorized users who message your bot will appear here.
- OpenRouter offers many models, including free ones. To get started, you can pick a free model such as:
- nvidia/llama-3.3-nemotron-super-49b-v1:free
- Browse all available models at openrouter.ai/models — filter by "Free" to see no-cost options.
-
-
- Head over to the Setup page (left sidebar), select OpenRouter as provider, paste your API key, and enter the model name.
-
- Want a different provider (Anthropic, DeepSeek, Groq, Cerebras, Mistral, etc.)? Open the
- Hermes Dashboard from the sidebar — the
- Keys tab lists every provider Hermes supports out of the box, plus
- skills, toolsets, and analytics you can configure from there.
-
-
-
-
-
-
-
-
- 2
- Connect a Messaging Platform
-
-
- Hermes Agent works through messaging platforms. You need to connect at least one. The easiest to set up is Telegram.
-
- Send /newbot and follow the prompts — give your bot a name and username.
-
-
- BotFather will reply with a Bot Token — it looks like 1234567890:ABCdefGhIjKlMnOpQrStUvWxYz.
- Copy this token.
-
-
- Go to the Setup page, enable Telegram under Messaging Channels, and paste the Bot Token.
-
-
- For Allowed User IDs, if you're just starting out, enter * to allow all users.
- You can restrict access later by specifying individual Telegram user IDs.
-
-
- Click Save & Start on the bottom right — your Hermes Agent is now live!
-
-
-
-
-
-
- Facing an issue or something not working as expected? Create a GitHub issue and we'll help you out.
-
-
-
-
-
-
-
-
-
Other Templates
-
Deploy more AI agents on Railway
-
-
-
- Explore other AI agent templates you can deploy on Railway with one click.
-
-
-
-
-
-
OpenClaw
-
- Self-hosted OpenClaw agent — deploy and manage your own OpenClaw instance on Railway.
-
" in content:
- try:
- text = content.decode("utf-8", errors="replace")
- text = text.replace("", BACK_TO_SETUP_WIDGET + "", 1)
- content = text.encode("utf-8")
- except Exception:
- pass # on any error, fall back to raw upstream content
-
- return Response(
- content=content,
- status_code=upstream.status_code,
- headers=resp_headers,
- )
-
-
-async def route_root(request: Request) -> Response:
- """GET /: first-visit smart redirect, otherwise proxy to the dashboard.
-
- - Unconfigured + bare GET `/` → bounce to `/setup` so new users land on
- the wizard instead of a half-empty dashboard.
- - Sidebar / in-app links pass `?force=1` to opt out of that redirect —
- users who explicitly want the dashboard (e.g. to set providers via
- the Keys tab) can still reach it without saving config first.
- - Non-GET (SPA API calls, etc.) always proxy through.
- """
- if err := guard(request): return err
- if (request.method == "GET"
- and request.query_params.get("force") != "1"
- and not is_config_complete()):
- return RedirectResponse("/setup", status_code=302)
- return await _proxy_to_dashboard(request)
-
-
-async def route_proxy(request: Request) -> Response:
- """Catch-all: forward any unmatched path to the Hermes dashboard."""
- if err := guard(request): return err
- return await _proxy_to_dashboard(request)
-
-
-async def route_setup_404(request: Request) -> Response:
- """Typos under /setup/* should 404 here — not fall through to the proxy."""
- if err := guard(request): return err
- return Response("Not Found", status_code=404, media_type="text/plain")
-
-
-# ── App lifecycle ─────────────────────────────────────────────────────────────
-async def auto_start():
- if is_config_complete():
- asyncio.create_task(gw.start())
- else:
- print("[server] Config incomplete — gateway not started. Configure provider + model in the admin UI.", flush=True)
-
-
-@asynccontextmanager
-async def lifespan(app):
- # Dashboard runs always — it's the user-facing UI after setup is done,
- # and it's independent of gateway state.
- asyncio.create_task(dash.start())
- await auto_start()
- try:
- yield
- finally:
- await asyncio.gather(
- gw.stop(),
- dash.stop(),
- return_exceptions=True,
- )
- global _http_client
- if _http_client is not None:
- await _http_client.aclose()
- _http_client = None
-
-
-# ── WebSocket reverse proxy ──────────────────────────────────────────────────
-# The hermes dashboard exposes 4 WebSocket endpoints when started with --tui.
-# Three are opened by the browser SPA and need to flow through our reverse
-# proxy; the fourth (/api/pub) is opened only by the PTY child against
-# loopback and is intentionally NOT proxied — exposing it would let an
-# authed user spam events into channels.
-#
-# /api/pty binary stream — embedded TUI keystrokes/output
-# /api/ws JSON-RPC — gateway sidecar driving Chat metadata
-# /api/events text frames — dashboard subscriber for /api/pub fan-out
-#
-# Auth model (matches the HTTP proxy):
-# * Edge: our HMAC cookie via _is_authenticated. WebSocket inherits .cookies
-# from starlette HTTPConnection so the same helper works unchanged.
-# * Upstream: hermes's own ?token=<_SESSION_TOKEN> query param. The SPA
-# fetches that token via /api/auth/session-token and includes it in the
-# WS URL, so we just forward path + query verbatim.
-PROXIED_WS_PATHS = ("/api/pty", "/api/ws", "/api/events")
-
-
-async def _ws_pump_client_to_upstream(
- client: WebSocket,
- upstream: websockets.WebSocketClientProtocol,
-) -> None:
- """Forward client → upstream until the client side disconnects.
-
- Handles both binary (PTY bytes) and text (JSON-RPC) frames.
- """
- try:
- while True:
- msg = await client.receive()
- if msg.get("type") == "websocket.disconnect":
- return
- data = msg.get("bytes")
- if data is not None:
- await upstream.send(data)
- continue
- text = msg.get("text")
- if text is not None:
- await upstream.send(text)
- except (WebSocketDisconnect, websockets.exceptions.ConnectionClosed):
- return
- except Exception as e:
- print(f"[ws-proxy] client→upstream error on {client.url.path}: {e!r}", flush=True)
- return
-
-
-async def _ws_pump_upstream_to_client(
- upstream: websockets.WebSocketClientProtocol,
- client: WebSocket,
-) -> None:
- """Forward upstream → client until upstream closes."""
- try:
- async for msg in upstream:
- if isinstance(msg, bytes):
- await client.send_bytes(msg)
- else:
- await client.send_text(msg)
- except (websockets.exceptions.ConnectionClosed, WebSocketDisconnect):
- return
- except Exception as e:
- print(f"[ws-proxy] upstream→client error on {client.url.path}: {e!r}", flush=True)
- return
-
-
-async def ws_proxy(websocket: WebSocket) -> None:
- """Reverse-proxy a single WebSocket from browser → hermes dashboard.
-
- Order matters: connect upstream BEFORE accepting the client. If hermes
- is wedged or rejects the upgrade, we close the client with a meaningful
- code instead of accepting and then dropping silently.
-
- Connection lifecycle:
- 1. Verify edge cookie auth → 4401 close on failure
- 2. Open upstream WS with bounded open_timeout → 1011 on failure
- 3. Accept client
- 4. Spawn two pump tasks (bidirectional byte forwarding)
- 5. When either direction ends (client navigates away, upstream PTY
- exits, etc.), cancel the other task and close both sockets
- """
- # 1. Edge auth.
- if not _is_authenticated(websocket):
- # Close before accept — browser sees the handshake fail (expected
- # for unauthenticated calls).
- await websocket.close(code=4401)
- return
-
- # 2. Build upstream URL preserving the SPA's path + query (the query
- # contains the hermes session token + channel id).
- path = websocket.url.path
- qs = websocket.url.query
- upstream_url = f"ws://{HERMES_DASHBOARD_HOST}:{HERMES_DASHBOARD_PORT}{path}"
- if qs:
- upstream_url = f"{upstream_url}?{qs}"
-
- try:
- upstream = await websockets.connect(
- upstream_url,
- open_timeout=5,
- # Don't forward client cookies/headers — hermes WS auth is
- # purely token-based via the URL, and forwarding random
- # headers risks future upstream surprises.
- )
- except (asyncio.TimeoutError, OSError, websockets.exceptions.WebSocketException) as e:
- # Hermes dashboard down, restarting, or rejected the upgrade
- # (e.g. bad/missing session token).
- print(f"[ws-proxy] upstream connect failed for {path}: {e!r}", flush=True)
- # 1011 = internal error; client SPA will surface a generic close.
- await websocket.close(code=1011)
- return
-
- # 3. Both sides ready — accept and start pumping.
- await websocket.accept()
-
- pump_in = asyncio.create_task(_ws_pump_client_to_upstream(websocket, upstream))
- pump_out = asyncio.create_task(_ws_pump_upstream_to_client(upstream, websocket))
-
- try:
- # First side to finish wins; cancel the other.
- done, pending = await asyncio.wait(
- (pump_in, pump_out),
- return_when=asyncio.FIRST_COMPLETED,
- )
- for task in pending:
- task.cancel()
- try:
- await task
- except (asyncio.CancelledError, Exception):
- pass
- finally:
- # websockets.connect() outside `async with` doesn't auto-close;
- # do it explicitly. Same for the client side if still open.
- try:
- await upstream.close()
- except Exception:
- pass
- if websocket.client_state == WebSocketState.CONNECTED:
- try:
- await websocket.close()
- except Exception:
- pass
-
-
-ANY_METHOD = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
-
-routes = [
- # Public — no auth required.
- Route("/health", route_health),
- Route("/login", page_login, methods=["GET"]),
- Route("/login", login_post, methods=["POST"]),
- Route("/logout", logout),
-
- # Our setup wizard + management API, all under /setup/* (cookie-auth guarded).
- Route("/setup", page_index),
- Route("/setup/", page_index),
- Route("/setup/api/config", api_config_get, methods=["GET"]),
- Route("/setup/api/config", api_config_put, methods=["PUT"]),
- Route("/setup/api/status", api_status),
- Route("/setup/api/logs", api_logs),
- Route("/setup/api/gateway/start", api_gw_start, methods=["POST"]),
- Route("/setup/api/gateway/stop", api_gw_stop, methods=["POST"]),
- Route("/setup/api/gateway/restart", api_gw_restart, methods=["POST"]),
- Route("/setup/api/config/reset", api_config_reset, methods=["POST"]),
- Route("/setup/api/pairing/pending", api_pairing_pending),
- Route("/setup/api/pairing/approve", api_pairing_approve, methods=["POST"]),
- Route("/setup/api/pairing/deny", api_pairing_deny, methods=["POST"]),
- Route("/setup/api/pairing/approved", api_pairing_approved),
- Route("/setup/api/pairing/revoke", api_pairing_revoke, methods=["POST"]),
-
- # /setup/* typos return a real 404 — not a silent proxy fallthrough.
- Route("/setup/{path:path}", route_setup_404, methods=ANY_METHOD),
-
- # Reverse-proxy hermes's dashboard WebSockets (Chat tab + sidecar).
- # WebSocketRoute is matched independently of HTTP routes, so order
- # relative to the catch-all HTTP `Route("/{path:path}", ...)` below
- # doesn't matter — but listing them as a group keeps the surface
- # area auditable. Only paths in PROXIED_WS_PATHS are forwarded;
- # /api/pub is intentionally omitted.
- WebSocketRoute("/api/pty", ws_proxy),
- WebSocketRoute("/api/ws", ws_proxy),
- WebSocketRoute("/api/events", ws_proxy),
-
- # Root: redirect to /setup if unconfigured, otherwise proxy the dashboard.
- Route("/", route_root, methods=ANY_METHOD),
-
- # Catch-all: everything else proxies to the Hermes dashboard subprocess.
- Route("/{path:path}", route_proxy, methods=ANY_METHOD),
-]
-
-# No middleware — auth is enforced per-handler via guard(). This keeps /health
-# and /login truly unauthenticated without middleware gymnastics.
-app = Starlette(routes=routes, lifespan=lifespan)
-
-if __name__ == "__main__":
- import uvicorn
- port = int(os.environ.get("PORT", "8080"))
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info", loop="asyncio")
- server = uvicorn.Server(config)
-
- def _shutdown():
- loop.create_task(gw.stop())
- loop.create_task(dash.stop())
- server.should_exit = True
-
- for sig in (signal.SIGTERM, signal.SIGINT):
- loop.add_signal_handler(sig, _shutdown)
-
- loop.run_until_complete(server.serve())
diff --git a/start.sh b/start.sh
index 3edfbf7..6034def 100644
--- a/start.sh
+++ b/start.sh
@@ -1,10 +1,8 @@
#!/bin/bash
-set -e
+set -euo pipefail
+
+# Required: tini delivers SIGTERM here, we forward to the gateway via `exec`.
-# Mirror dashboard-ref-only's startup: create every directory hermes expects
-# and seed a default config.yaml if the volume is empty. Without these,
-# `hermes dashboard` endpoints that hit logs/, sessions/, cron/, etc. can fail
-# with opaque errors even though no auth is actually involved.
mkdir -p /data/.hermes/cron /data/.hermes/sessions /data/.hermes/logs \
/data/.hermes/memories /data/.hermes/skills /data/.hermes/pairing \
/data/.hermes/hooks /data/.hermes/image_cache /data/.hermes/audio_cache \
@@ -13,16 +11,28 @@ mkdir -p /data/.hermes/cron /data/.hermes/sessions /data/.hermes/logs \
if [ ! -f /data/.hermes/config.yaml ] && [ -f /opt/hermes-agent/cli-config.yaml.example ]; then
cp /opt/hermes-agent/cli-config.yaml.example /data/.hermes/config.yaml
fi
-
[ ! -f /data/.hermes/.env ] && touch /data/.hermes/.env
-# Clear any stale gateway PID file left over from the previous container.
-# `hermes gateway` writes /data/.hermes/gateway.pid on start but does not
-# remove it on SIGTERM. Since /data is a persistent volume, the file
-# survives container restarts and causes every subsequent boot to exit with
-# "ERROR gateway.run: PID file race lost to another gateway instance".
-# No hermes process can be running at this point (we're pre-exec in a fresh
-# container), so removing the file unconditionally is safe.
-rm -f /data/.hermes/gateway.pid
+# `hermes gateway run --replace` (added upstream) supersedes the manual
+# stale-PID cleanup the old server.py needed.
+
+: "${ADMIN_USERNAME:?ADMIN_USERNAME must be set}"
+: "${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set}"
+: "${PORT:=8080}"
+
+# bcrypt hash so plaintext never lands on disk
+ADMIN_PASSWORD_HASH="$(caddy hash-password --plaintext "$ADMIN_PASSWORD")"
+export ADMIN_USERNAME ADMIN_PASSWORD_HASH PORT
+
+# Caddy's own envsubst-equivalent is `{$VAR}` in Caddyfile syntax, so we
+# can hand it the template directly.
+cp /app/Caddyfile.tmpl /tmp/Caddyfile
+
+# Native dashboard on loopback — Caddy fronts it with basic auth at the edge.
+hermes dashboard --host 127.0.0.1 --port 9119 --no-open --tui &
+
+caddy run --config /tmp/Caddyfile --adapter caddyfile &
-exec python /app/server.py
+# Foreground: tini → start.sh → exec → hermes gateway. SIGTERM reaches the
+# gateway directly so its own shutdown handlers run.
+exec hermes gateway run --replace
diff --git a/templates/index.html b/templates/index.html
deleted file mode 100644
index 96dc1a9..0000000
--- a/templates/index.html
+++ /dev/null
@@ -1,1469 +0,0 @@
-
-
-