Skip to main content

Deploy the Hermes agent

This worked example deploys Nous Research's Hermes agent (Docker image nousresearch/hermes-agent:latest) onto a Miren cluster. Hermes ships only as a Docker image, so Miren pulls and runs that image directly.

The end state: the Hermes dashboard served over HTTPS behind basic auth, the OpenAI-compatible API server intentionally disabled, and all agent state persisted on a Miren disk at /opt/data.

Along the way it exercises a lot of real Miren behavior: direct image deploys, the web service convention, 0.0.0.0 binding, port_timeout, and network disks — so it doubles as a tour of what matters when you bring your own image.

This is an application recipe, not a language guide

For getting your own source code onto Miren, start with Deployment and the Language Guides. This page is about running a prebuilt third-party image.

Prerequisites

  • miren CLI installed and authenticated (miren whoami).
  • Access to the target cluster and its org.
  • An Anthropic API key for the agent's LLM calls. (This example uses Anthropic; the OPENAI_API_KEY in app.toml is optional — supply it too if your agent uses OpenAI.)

Select the target cluster

If the cluster isn't configured locally yet, list what your identity can see and add it (this pins the TLS fingerprint):

# List clusters available to your cloud identity
miren cluster add -i cloud

# Add it by name + public address (from the list above)
miren cluster add -c hermes -a <your-cluster-ip>:8443 -i cloud

miren whoami -C hermes

The commands below target it explicitly with -C hermes. Omit -C to use your default cluster.

The app.toml

name = "hermes"

[services.web]
image = "nousresearch/hermes-agent:latest"
args = ["gateway", "run"]
port = 9119
port_type = "http"
port_timeout = "180s"

[services.web.concurrency]
mode = "fixed"
num_instances = 1

[[services.web.disks]]
name = "data"
provider = "miren"
mount_path = "/opt/data"
size_gb = 10
filesystem = "ext4"

# Dashboard (the single routed, health-checked port), basic auth.
[[env]]
key = "HERMES_DASHBOARD"
value = "1"
[[env]]
key = "HERMES_DASHBOARD_HOST"
value = "0.0.0.0"
[[env]]
key = "HERMES_DASHBOARD_PORT"
value = "9119"
[[env]]
key = "HERMES_DASHBOARD_BASIC_AUTH_USERNAME"
value = "admin" # change to any username you prefer
[[env]]
key = "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD"
required = true
sensitive = true

[[env]]
key = "ANTHROPIC_API_KEY"
required = true
sensitive = true
[[env]]
key = "OPENAI_API_KEY"
sensitive = true

The image's ENTRYPOINT is /opt/hermes/docker/entrypoint-dispatch.sh. When it runs as PID 1, the dispatcher starts s6-overlay and passes the arguments through to Hermes' main wrapper. The args field supplies gateway and run as separate arguments without replacing that startup path. The image doesn't expose a port, so the recipe declares the dashboard's port explicitly.

Name the HTTP service web

Miren's HTTP ingress routes a hostname to the app's web service by default, so the easiest layout is to name the HTTP service web. Name it something else and miren route set rejects it unless you pass --service <name>. If you want a portless background worker instead (reachable only via a messaging platform or miren sandbox exec), name the service something else and omit port/port_type — then Miren does no HTTP ingress and no port health check.

Two details in that config bite if you skip them. Miren health-checks and routes to the port from outside the container, but Hermes components default to 127.0.0.1 — set HERMES_DASHBOARD_HOST=0.0.0.0 (and API_SERVER_HOST=0.0.0.0 if you ever enable the API server) or the health check reports nothing is listening on :<port>. And first boot is slow: s6 init, first-boot seeding, and a config-schema migration all run before the port binds, so port_timeout = "180s" keeps the check from giving up at the 15-second default.

A Miren disk forces single-instance, churny rollouts

A provider = "miren" (network) disk holds one exclusive lease, so it requires concurrency mode = "fixed" with num_instances = 1. On redeploy the new instance can't mount /opt/data until the old one releases the lease, so miren deploy frequently prints did not become healthy while the instance actually comes up a few seconds later — re-check miren app status / miren sandbox list before assuming failure. If you'd rather have snappy rollouts and don't need node-independent storage, use provider = "local" (no lease, no fixed-instance requirement; data is node-local).

Keep the OpenAI API server disabled unless sandboxed

Hermes warns that a network-accessible OpenAI-compatible API server with the default local terminal backend runs agent work as the host user with full file and terminal access. This config leaves it off. Enable it only deliberately and sandboxed (terminal.backend: docker) with the port firewalled.

Deploy

Non-secret config lives in app.toml. Pass secrets with -s (masked in output, stored server-side); never bake them into the image:

# Generate the dashboard password once and save it somewhere safe:
DASH_PW=$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-20)

miren deploy -a hermes -C hermes -f \
-s "ANTHROPIC_API_KEY=sk-ant-..." \
-s "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=$DASH_PW"

Validate the config without building at any time:

miren deploy --analyze -a hermes -C hermes
miren env delete redeploys the current server spec

To remove a previously-set secret (manual) env var, use miren env delete — but it builds the new version by copying the current server-side spec, not your local app.toml. Order matters: deploy your new app.toml first, then delete the stale env var. Config-source vars (declared in app.toml) drop automatically when you remove them and redeploy.

Add a hostname route

miren route set gw.hermes.clusters.miren.run hermes -C hermes
miren route list -C hermes

route set <host> <app> routes the host to the app's web service by default; pass --service <name> to target another HTTP service. *.clusters.miren.run DNS and TLS are managed by the cluster; give it a few seconds to provision.

Verify

miren app status -a hermes -C hermes        # Current Version + active
miren sandbox list -C hermes # one running sandbox, service "web"
miren logs -a hermes -C hermes --since 5m # look for HERMES_DASHBOARD_READY port=9119

# Route should 302 -> /login (200), served by uvicorn:
curl -sL -o /dev/null -w "%{http_code} %{url_effective}\n" https://gw.hermes.clusters.miren.run/

Healthy signs in the logs: s6-rc: service main-hermes successfully started, Fixing ownership of /opt/data, config schema 0 -> NN, and HERMES_DASHBOARD_READY port=9119.

Connecting to the agent

  • Dashboard: https://gw.hermes.clusters.miren.run (the basic-auth username from app.tomladmin unless you changed it — plus the password from the deploy step).
  • CLI (no port needed): miren sandbox exec <sandbox-id> -C hermes, then run the hermes CLI inside the container.
  • Messaging platform (no inbound port): set TELEGRAM_BOT_TOKEN; the agent dials out and you DM the bot.

Roadblock checklist

  1. Set args = ["gateway", "run"] so Miren preserves the image's dispatcher ENTRYPOINT and supplies the gateway subcommand.
  2. Name the HTTP service web (or pass --service <name> to miren route set).
  3. Bind services to 0.0.0.0, not 127.0.0.1.
  4. port_timeout = "180s" for the slow s6 first boot.
  5. A miren disk means fixed / num_instances = 1; expect did not become healthy noise on redeploy (verify state before retrying). Use local for snappy rollouts.
  6. Deploy the new app.toml before miren env delete (it copies the live spec).
  7. Keep the OpenAI API server off unless sandboxed — it runs agent work as the host user.

Next steps