Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions templates/hermes/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,49 @@
# Pinned by digest; the upstream tag floats.
FROM docker.io/nousresearch/hermes-agent:v2026.8.27@sha256:e0df6adebddf29b91112aefc999d4aaf6846c9eb544faca5672a16a13590ff79

# nginx fronts the one port the platform routes, so two listeners can share it: the dashboard, and
# the Telegram webhook server the gateway opens when TELEGRAM_WEBHOOK_URL is set. The webhook path
# must not sit behind the dashboard's sign-in: Telegram authenticates each update with its own
# secret token, never with our username and password. See nginx.conf. Upstream is Debian 13, so
# nginx-light comes from apt like every other package in the image.
RUN apt-get update && apt-get install -y --no-install-recommends nginx-light patch \
&& rm -rf /var/lib/apt/lists/*
COPY nginx.conf /etc/nginx/hermes.conf

# Telegram webhook mode already exists upstream, but on boot it registers the webhook with
# drop_pending_updates=True, on the (wrong) theory that Telegram keeps no queue for webhooks. It
# does keep every delivery it has no 2xx for yet, and on a machine that scales to zero the update
# that woke the machine is in flight while the gateway boots. Dropping pending updates there lost
# the first message after every wake (seen on the PoC). One line: keep them, Telegram redelivers.
COPY telegram-keep-pending-updates.patch /tmp/telegram-keep-pending-updates.patch
RUN patch -p1 -d /opt/hermes --forward --no-backup-if-mismatch < /tmp/telegram-keep-pending-updates.patch \
&& rm /tmp/telegram-keep-pending-updates.patch \
&& python3 -m py_compile /opt/hermes/plugins/platforms/telegram/adapter.py

# Slack over the inbound Events API. Upstream's Slack adapter speaks Socket Mode only, an outbound
# connection a scale-to-zero platform cannot see; slack_bolt ships aiohttp helpers for the Events
# API, and this patch wires them in behind SLACK_SIGNING_SECRET plus SLACK_EVENTS_URL. This image
# offers no Socket Mode. Applied against the digest-pinned upstream file, so a pin bump that touches
# the adapter fails this step loudly instead of drifting. Candidate for an upstream PR; see
# slack-events-api.patch for the change itself.
COPY slack-events-api.patch /tmp/slack-events-api.patch
RUN patch -p1 -d /opt/hermes --forward --no-backup-if-mismatch < /tmp/slack-events-api.patch \
&& rm /tmp/slack-events-api.patch \
&& python3 -m py_compile /opt/hermes/plugins/platforms/slack/adapter.py

# The dashboard's Channels page and the Slack plugin manifest describe Slack in Socket Mode terms
# (app-level token required). This image runs Slack over the Events API only, so the same patch
# mechanism rewrites that one card: bot token plus signing secret, the request URL to paste into the
# Slack app spelled out, and no app-level token field. One place to configure Slack, one story.
COPY dashboard-slack-events.patch /tmp/dashboard-slack-events.patch
RUN patch -p1 -d /opt/hermes --forward --no-backup-if-mismatch < /tmp/dashboard-slack-events.patch \
&& rm /tmp/dashboard-slack-events.patch \
&& python3 -m py_compile /opt/hermes/hermes_cli/web_server.py /opt/hermes/hermes_cli/config_defaults.py

# Upstream's main process is the interactive TUI, which exits without a TTY. This
# image supplies the main process instead: bootstrap auth, bring up the s6-supervised
# gateway service, then serve the dashboard. Upstream's ENTRYPOINT stays: it routes
# an executable through, and on a deployed machine PID 1 is s6-svscan (a 2.1.x note
# gateway service, then serve the dashboard behind nginx. Upstream's ENTRYPOINT stays: it
# routes an executable through, and on a deployed machine PID 1 is s6-svscan (a 2.1.x note
# here claimed Fly skips s6; observed machines say otherwise), so the s6 service
# tree the entrypoint relies on is present.
COPY entrypoint.sh /entrypoint.sh
Expand Down
73 changes: 63 additions & 10 deletions templates/hermes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ This template runs [Hermes Agent](https://github.com/NousResearch/hermes-agent)
Hermes is an autonomous agent: you give it a task and it works on it with its own tools rather
than answering a single prompt. The template wraps the upstream image with a start command
(upstream's default process is an interactive terminal UI that exits without a TTY) and runs the
web dashboard as the main process with the messaging gateway supervised beside it, state on a
persistent volume.
web dashboard behind a small nginx on the service port, with the messaging gateway supervised
beside it, state on a persistent volume. nginx exists so the same port can also publish the
Telegram and Slack webhook endpoints, which is what lets those bots keep working on a machine that
scales to zero (see [Scale to zero](#scale-to-zero)).

The dashboard is what the service URL serves. Chat lives there too, so a fresh deploy is usable
with no messaging platform configured at all. When you want the agent on Telegram, Slack, Discord,
Expand Down Expand Up @@ -51,18 +53,32 @@ skip that step), and messaging platforms on the Channels page.
| `ADMIN_USERNAME` | yes | Sign-in username for the dashboard. You choose it; it may not contain a colon (HTTP basic auth uses one to separate user from password) or start with a dash. |
| `ADMIN_PASSWORD` | yes | Sign-in password for the dashboard. You choose it. |
| `OPENROUTER_API_KEY` | no | Key the agent uses for model calls. Get one at <https://openrouter.ai/keys>, or add it later on the dashboard's API Keys page; chat needs it to answer. |
| `TELEGRAM_BOT_TOKEN` | no | Token for a Telegram bot, created with @BotFather. Leave blank to connect Telegram (or anything else) later from the Channels page. |
| `TELEGRAM_BOT_TOKEN` | no | Token for a Telegram bot, created with @BotFather. Telegram is connected over an inbound webhook that the template registers for you, so it is the one channel that keeps working when the machine scales to zero. Leave blank to connect Telegram (or anything else) later from the Channels page. |
| `TELEGRAM_ALLOWED_USERS` | no | Comma-separated **numeric** Telegram user IDs allowed to use the bot. Not @usernames: the adapter compares the user id and never reads the username. Get yours from @userinfobot. |
| `DISCORD_BOT_TOKEN` | no | Token for a Discord bot from the Discord Developer Portal. |
| `DISCORD_BOT_TOKEN` | no | Token for a Discord bot from the Discord Developer Portal. Discord delivers messages over a connection the bot opens, so turn always-on on in the console before connecting it. |
| `DISCORD_ALLOWED_USERS` | no | Comma-separated numeric Discord user IDs allowed to use the bot. |
| `SLACK_BOT_TOKEN` | no | Slack bot token (`xoxb-...`). Slack needs `SLACK_APP_TOKEN` too. |
| `SLACK_APP_TOKEN` | no | Slack app-level token (`xapp-...`) for Socket Mode, which needs no public callback URL. |
| `SLACK_BOT_TOKEN` | no | Slack bot token (`xoxb-...`) from OAuth & Permissions. Slack reaches this deployment over its inbound Events API, so the bot works on a scale-to-zero machine; `SLACK_SIGNING_SECRET` goes with it. |
| `SLACK_SIGNING_SECRET` | no | The Slack app's signing secret, from Basic Information > App Credentials. Needed whenever `SLACK_BOT_TOKEN` is set; see [Scale to zero](#scale-to-zero) for the one-time setup in the Slack app. This image does not offer Slack's Socket Mode (no app-level token). |
| `SLACK_ALLOWED_USERS` | no | Comma-separated Slack member IDs (e.g. `U01ABC2DEF3`) allowed to use the bot. |
| `HERMES_GATEWAY_TOKEN` | generated | Generated 64-character token; you do not set this. |

Set by the template, not by you: `HERMES_HOME=/data/.hermes` (state on the volume) and
`HERMES_DASHBOARD_PORT=8080`. The entrypoint binds the dashboard and starts the gateway as a
managed daemon the dashboard's System and Channels pages control.
| `TELEGRAM_WEBHOOK_SECRET` | generated | Generated 32-character token Telegram signs each webhook update with; you do not set this, and only the machine and Telegram ever see it. |

Set by the template, not by you: `HERMES_HOME=/data/.hermes` (state on the volume),
`HERMES_DASHBOARD_PORT=8081` (the dashboard listens on a port the platform does not route, behind
nginx, which owns the service port 8080), the Telegram webhook settings `TELEGRAM_WEBHOOK_URL=<service URL>/telegram`,
`TELEGRAM_WEBHOOK_PORT=8443`, `TELEGRAM_WEBHOOK_HOST=127.0.0.1`, and the Slack Events API settings
`SLACK_EVENTS_URL=<service URL>/slack/events`, `SLACK_EVENTS_PORT=8444`, `SLACK_EVENTS_HOST=127.0.0.1`.
The entrypoint checks the nginx config, starts nginx and the dashboard, and starts the gateway as
a managed daemon the dashboard's System and Channels pages control.

The image applies three small patches to upstream at build time. `slack-events-api.patch`: the
Slack adapter serves Slack's Events API through slack_bolt's own HTTP helpers (upstream speaks
Socket Mode only). `dashboard-slack-events.patch`: the Channels page's Slack card asks for the
signing secret and shows the Request URL instead of asking for a Socket Mode app-level token.
`telegram-keep-pending-updates.patch`: upstream registers the Telegram webhook with
`drop_pending_updates=True` on every boot, and on a machine that scales to zero the message that
woke the machine is still in flight while the gateway boots, so that flag threw away the first
message after every wake; the patch keeps pending updates and Telegram redelivers them.

## After deploy

Expand All @@ -80,6 +96,43 @@ managed daemon the dashboard's System and Channels pages control.
6. State lives under `/data/.hermes`, so restarts and redeploys keep the agent's memory. Deleting
the volume resets it.

## Scale to zero

The platform decides a machine is idle from the traffic it can see, which is inbound traffic
through its router. Messaging channels differ in which direction their traffic flows, and that
decides whether a channel survives scale to zero:

- **Telegram: yes.** The template registers a webhook with Telegram at deploy time (the
`TELEGRAM_WEBHOOK_*` settings above), so Telegram pushes each update to the service URL. That is
inbound traffic: it wakes a stopped machine, the gateway handles the update, and the machine can
scale to zero again afterwards. Telegram waits for the answer and retries deliveries, so the
first message after an idle period is answered once the machine is up: a wake takes ten to
twenty seconds and the gateway a few more to start, so expect the reply to that first message
after about half a minute, and later messages within seconds.
- **Slack: yes.** This image runs Slack over the Events API only. Set `SLACK_BOT_TOKEN` and
`SLACK_SIGNING_SECRET` (at deploy time or later on the dashboard's Channels page, which shows the
exact Request URL), then in the Slack app's settings: turn **Socket Mode** off, open **Event
Subscriptions**, enable events, and paste `<service URL>/slack/events` as the Request URL. Slack
verifies the URL immediately, so open the dashboard first so the machine is awake, and it must
also be awake later when you save scope or event changes. Subscribe to the bot events Hermes
documents (`message.im`, `message.channels`, `message.groups`, `message.mpim`, `app_mention`) and
reinstall the app. From then on Slack pushes each event to the service URL: inbound traffic that
wakes the machine. Slack expects an answer within 3 seconds and a wake takes about 15, so the
first message after an idle period is delivered on Slack's retry about a minute later; messages
while the machine is awake are answered at once. A bot token without the signing secret is
reported as a configuration error on the Channels page rather than silently falling back to
Socket Mode, and a bot that goes quiet after the switch almost always has the Slack app's Socket
Mode toggle still on.
- **Discord: no.** The Discord gateway is a connection the bot opens outward. The router never sees
it, an idle machine is stopped, the connection dies, and a Discord message cannot wake it: the bot
stays silent until someone opens the dashboard. Discord offers no inbound delivery for channel
messages, so Discord needs always-on.

The template ships with the platform's scale-to-zero default: the machine stops while idle and
wakes on the next dashboard visit, Telegram message or Slack event, and you pay only for the time it
runs. If you connect Discord, turn always-on on in the console first; it costs a small continuous
RAM charge and keeps that connection alive.

## Links

- Documentation: <https://hermes-agent.nousresearch.com/docs/>
Expand Down
120 changes: 120 additions & 0 deletions templates/hermes/dashboard-slack-events.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -8895,11 +8895,18 @@
"required_env": ("DISCORD_BOT_TOKEN",),
},
"slack": {
+ # InstaCloud template patch: this image runs Slack over the inbound Events API (see
+ # plugins/platforms/slack/adapter.py), so the card asks for the signing secret instead
+ # of the Socket Mode app-level token.
"name": "Slack",
- "description": "Use Hermes from Slack via Socket Mode. Add allowed Slack member IDs so connected bots can respond.",
+ "description": (
+ "Use Hermes from Slack over the Events API: Slack posts each event to this "
+ "deployment's URL, so the bot keeps working on a machine that scales to zero. "
+ "Add allowed Slack member IDs so connected bots can respond."
+ ),
"docs_url": "https://api.slack.com/apps",
- "env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"),
- "required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
+ "env_vars": ("SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "SLACK_ALLOWED_USERS"),
+ "required_env": ("SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"),
},
"mattermost": {
"name": "Mattermost",
@@ -9152,6 +9159,26 @@
# toggles, Twilio, HASS, Email, etc.). Anything missing from OPTIONAL_ENV_VARS
# falls back here so the UI can still render a friendly label.
_MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = {
+ # InstaCloud template patch: the Events API credential the Slack card asks for. The help text
+ # spells out the request URL this deployment serves, read from the environment the template
+ # sets, so the operator can paste it into the Slack app without looking anything up.
+ "SLACK_SIGNING_SECRET": {
+ "description": (
+ "Slack app signing secret, used to verify the events Slack posts to this deployment. "
+ "Basic Information > App Credentials > Signing Secret."
+ ),
+ "prompt": "Slack Signing Secret",
+ "help": (
+ "In the Slack app: turn Socket Mode OFF, open Event Subscriptions, enable events and "
+ "set the Request URL to "
+ + (os.environ.get("SLACK_EVENTS_URL") or "<this deployment's URL>/slack/events")
+ + " (keep the dashboard open while Slack verifies it), subscribe to the bot events "
+ "message.im, message.channels, message.groups, message.mpim and app_mention, then "
+ "reinstall the app to the workspace."
+ ),
+ "url": "https://api.slack.com/apps",
+ "password": True,
+ },
"SIGNAL_HTTP_URL": {
"description": "signal-cli REST API base URL, e.g. http://127.0.0.1:8080",
"prompt": "Signal bridge URL",
@@ -9484,6 +9511,14 @@

def _messaging_env_info(key: str) -> dict[str, Any]:
info = OPTIONAL_ENV_VARS.get(key) or _MESSAGING_ENV_FALLBACKS.get(key) or {}
+ # InstaCloud template patch: a plugin manifest entry carries no `help`, so fill it (and a
+ # missing url) from the fallback table, which is where the Slack request URL guidance lives.
+ fallback = _MESSAGING_ENV_FALLBACKS.get(key)
+ if fallback and info is not fallback:
+ info = dict(info)
+ for field in ("help", "url", "description"):
+ if not info.get(field) and fallback.get(field):
+ info[field] = fallback[field]
return {
"description": info.get("description", ""),
"prompt": info.get("prompt", key),
--- a/hermes_cli/config_defaults.py
+++ b/hermes_cli/config_defaults.py
@@ -4694,16 +4694,9 @@
"password": True,
"category": "messaging",
},
- "SLACK_APP_TOKEN": {
- "description": "Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → "
- "App-Level Tokens. Also ensure Event Subscriptions include: message.im, "
- "message.channels, message.groups, message.mpim, app_mention",
- "prompt": "Slack App Token (xapp-...)",
- "help": "In your Slack app, enable Socket Mode, then create Basic Information > App-Level Tokens with the connections:write scope.",
- "url": "https://api.slack.com/apps",
- "password": True,
- "category": "messaging",
- },
+ # InstaCloud template patch: SLACK_APP_TOKEN (Socket Mode) is not offered in this image, which
+ # runs Slack over the inbound Events API; the dashboard's Channels page and `hermes gateway
+ # setup` both read this table, so removing it here removes the field from both.
"SLACK_ALLOWED_USERS": {
"description": "Comma-separated Slack member IDs allowed to use Hermes, e.g. U01ABC2DEF3. Without this, Slack may connect but deny messages by default.",
"prompt": "Allowed Slack member IDs",
--- a/plugins/platforms/slack/plugin.yaml
+++ b/plugins/platforms/slack/plugin.yaml
@@ -4,10 +4,12 @@
version: 1.0.0
description: >
Slack gateway adapter for Hermes Agent.
- Connects to Slack via slack-bolt in Socket Mode and relays messages
- between Slack channels/DMs and the Hermes agent. Supports slash
- commands, threads, mrkdwn rendering, approval blocks, free-response
- channels, mention gating, and channel skill bindings.
+ Receives Slack events over the Events API (slack-bolt, inbound HTTP) and
+ relays messages between Slack channels/DMs and the Hermes agent. Supports
+ slash commands, threads, mrkdwn rendering, approval blocks, free-response
+ channels, mention gating, and channel skill bindings. InstaCloud template
+ build: Socket Mode is not offered here, so a scale-to-zero machine can be
+ woken by an incoming event.
author: NousResearch
requires_env:
- name: SLACK_BOT_TOKEN
@@ -15,9 +17,9 @@
prompt: "Slack Bot Token (xoxb-...)"
url: "https://api.slack.com/apps"
password: true
- - name: SLACK_APP_TOKEN
- description: "Slack app-level token for Socket Mode (xapp-..., scope connections:write)"
- prompt: "Slack App Token (xapp-...)"
+ - name: SLACK_SIGNING_SECRET
+ description: "Slack app signing secret (Basic Information > App Credentials), verifies the events Slack posts to this deployment"
+ prompt: "Slack Signing Secret"
url: "https://api.slack.com/apps"
password: true
optional_env:
Loading
Loading