Hermex is a native iOS client for Hermes, and it’s a considerably better way to reach an agent from a phone than a Telegram thread. It doesn’t connect to the Hermes agent directly, though — it’s a client for hermes-webui, a separate open-source web UI and server. Getting Hermex working therefore means deploying hermes-webui alongside an agent you already run.

This guide covers that case specifically: an existing Hermes deployment in Docker, with sessions, memory and skills you’d rather not put at risk. The end state is Hermex on your phone, talking to your real agent, with your real history — and a hermes-webui that is structurally incapable of interfering with any of it.

The default configuration runs a second agent

hermes-webui executes the Hermes agent in-process, inside its own container. The documented way to connect it to an existing deployment is to share HERMES_HOME, which gives you two independent runtimes executing turns against the same state.db, memory and profiles. Nothing warns you, and both containers report healthy. The whole point of the configuration below is to avoid this.

How the pieces fit

Two decisions do the work.

Chat is relayed, not executed. Setting HERMES_WEBUI_CHAT_BACKEND=gateway routes every browser turn through the Hermes gateway API on your existing agent container. Upstream documents this in docs/advanced-chat-setup.md, where browser chat is described as taking “the same runtime/tool path as messaging surfaces.” Exactly one agent ever executes a turn, and hermes-webui becomes a front end to it.

This has a useful secondary effect. Tools invoked from the web UI otherwise run in the WebUI’s container (upstream #681), where none of the binaries you baked into your agent image exist — so skills that work from Telegram fail from your phone. Relaying turns puts execution back where the toolchain lives.

The agent’s state is mounted read-only. hermes-webui still needs to read your agent’s home: the session list is a direct SQLite read of $HERMES_HOME/state.db, and skills, profiles and model metadata come from the same place. Cutting it off entirely produces an app with no history and several broken endpoints. Mounting it :ro gives you the reads without the risk, and the guarantee is enforced by the kernel rather than by trusting upstream’s write paths to stay as they are today.

The resulting split:

where it happens
chat, tools, model callsrelayed to the agent container
sessions, config, profiles, skillsread directly, read-only
profile/provider/skill writesblocked — done from the CLI

Prerequisites

A running Hermes agent in Docker with the gateway API enabled (API_SERVER_ENABLED=true and a valid API_SERVER_KEY), on a Docker network hermes-webui can join. Everything below assumes the agent’s home is bind-mounted from /data/hermes on the host and that the service is named hermes.

1. Get the agent’s source onto the host

Even in gateway mode, hermes-webui imports Hermes’ Python libraries to read skills, kanban and plugin metadata. Without them the skills endpoint returns a 500 and kanban returns 503. This is importing libraries to read metadata, not running an agent — turns still relay.

The source lives inside your agent image, so copy it out to a host directory:

docker exec hermes tar -cf - -C /opt/hermes \
  --exclude=./.venv --exclude=./.playwright --exclude=./.git \
  --exclude='*.egg-info' --exclude=__pycache__ . \
  | tar -xf - -C /data/hermes-agent-src

Re-run this whenever you rebuild the agent image, and recreate the WebUI container afterwards so it picks up the new source.

Don't share it as a named volume

Upstream’s compose files mount a named volume over the agent’s own /opt/hermes. Docker populates a named volume from the image exactly once and reuses it verbatim thereafter, so your next image rebuild produces an agent whose fresh code is masked by a stale volume — silently running whatever it had on first boot. Copying the source out avoids this entirely.

Mount it outside HERMES_HOME

hermes-webui probes $HERMES_HOME/hermes-agent first, but a nested mountpoint inside a bind mount is created on the host as root, leaving root-owned directories inside your agent’s state. Use /opt/hermes — its second probe path — and set HERMES_WEBUI_AGENT_DIR explicitly, because the agent-directory discovery code never checks /opt/hermes even though the dependency installer does.

2. Add the service

hermes-webui:
  image: ghcr.io/nesquena/hermes-webui:latest
  container_name: hermes-webui
  restart: unless-stopped
  environment:
    # relay turns to the agent instead of running one
    - HERMES_WEBUI_CHAT_BACKEND=gateway
    - HERMES_WEBUI_GATEWAY_BASE_URL=http://hermes:8642
    - HERMES_WEBUI_GATEWAY_USE_RUNS_API=true
    - HERMES_WEBUI_GATEWAY_API_KEY=${HERMES_GATEWAY_API_KEY}
 
    # the agent's home (read-only) and its libraries
    - HERMES_HOME=/opt/data
    - HERMES_WEBUI_AGENT_DIR=/opt/hermes
    - HERMES_SKIP_CHMOD=1
 
    # the WebUI's own writable state, outside the read-only mount
    - HERMES_WEBUI_STATE_DIR=/home/hermeswebui/state
 
    # match the uid that owns the agent's home
    - WANTED_UID=10000
    - WANTED_GID=10000
 
    - HERMES_WEBUI_HOST=0.0.0.0
    - HERMES_WEBUI_PORT=8787
    - HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD:?refuse to start without a password}
  volumes:
    - /data/hermes:/opt/data:ro
    - /data/hermes-agent-src:/opt/hermes:ro
    - hermes_webui_state:/home/hermeswebui/state
  networks: [internal, edge]

Password auth does not fail closed

With HERMES_WEBUI_PASSWORD unset, hermes-webui logs a suggestion that you set one and then keeps serving an unauthenticated console with filesystem access to your agent. The :? makes Compose refuse to render instead. Treat the resulting password as a genuine front door. A single shared secret is thin protection for something with filesystem access to your agent, so it’s worth pairing with a network-level restriction rather than relying on it alone.

The state volume must sit under /home/hermeswebui

Docker creates named volumes root-owned, and the WebUI’s entrypoint only chowns the paths its image prepared — /home/hermeswebui, /app, /uv_cache, /workspace. Mount your state volume anywhere else and the unprivileged server exits on boot with Failed to verify state directory.

3. Supply the gateway key

The bridge authenticates with the agent’s API_SERVER_KEY, which Hermes generates for itself into $HERMES_HOME/.env. Read it from there at deploy time rather than keeping a second copy that can drift:

grep '^API_SERVER_KEY=' /data/hermes/.env | cut -d= -f2-

If the key is missing or wrong, chat returns gateway_auth_error while everything else in the UI continues working normally.

4. Expose it

Hermex needs an HTTPS URL. A reverse proxy in front of port 8787 is enough, plus the headers that let the WebUI build correct origins behind it:

    - HERMES_WEBUI_SECURE=1
    - HERMES_WEBUI_ALLOWED_ORIGINS=https://hermes-webui.example.com
    - HERMES_WEBUI_TRUST_FORWARDED_HOST=1
    - HERMES_WEBUI_TRUST_FORWARDED_PROTO=1

Without these the login POST is rejected as cross-origin, which presents as a redirect loop back to the sign-in page rather than an error.

Single-sign-on in front of this route is not an option: Hermex is a native client and cannot complete an interactive 2FA redirect. The password plus network-level restriction is the access control.

5. Connect the app

Hermex is free on the App Store and its onboarding assumes you are starting from nothing — it walks through installing hermes-webui on your machine and exposing it over HTTPS with a tunnelling service. All of that is already done, so skip straight past it.

The only screen that matters asks for a server URL and a password. Enter the HTTPS address the reverse proxy serves and the value of HERMES_WEBUI_PASSWORD, and the app connects. Your existing sessions appear in the session list, since they are read from the agent’s state.db on the mount configured earlier.

If the app reaches the server but rejects the login, check the origin headers from the previous step before suspecting the password — a cross-origin rejection and a wrong password look similar from the client. Confirming the password against the API directly separates the two:

curl -si -X POST https://hermes-webui.example.com/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"password":"..."}' | head -1

Verifying

Three checks confirm the parts that matter. That the read-only mount is real:

docker exec hermes-webui touch /opt/data/.probe
# touch: cannot touch '/opt/data/.probe': Read-only file system

That the bridge is actually enabled — /api/health/agent includes a redacted diagnostic block:

"gateway_chat": {
  "enabled": true, "backend": "gateway",
  "base_url_configured": true, "api_key_configured": true
}

And that the WebUI can reach the gateway with the key it was given:

docker exec hermes-webui python3 -c "
import os, urllib.request, json
r = urllib.request.Request(
    os.environ['HERMES_WEBUI_GATEWAY_BASE_URL'] + '/v1/models',
    headers={'Authorization': 'Bearer ' + os.environ['HERMES_WEBUI_GATEWAY_API_KEY']})
print(json.load(urllib.request.urlopen(r, timeout=10)))"

A model list means transport and authentication are both good.

Administrative writes

Anything that writes to the agent’s home from the web UI now fails:

OSError: [Errno 30] Read-only file system: '/opt/data/profiles/.deleted/testing-123'

The traceback is worth reading: that is hermes_cli executing inside the WebUI container, writing straight into the agent’s home without going near the gateway. Creating a profile, setting a provider key and installing a skill all behave this way — they are exactly the second-writer behaviour this setup exists to prevent, and read-only turns them from silent mutations into visible errors.

Do them from the side that owns the state:

docker exec hermes hermes profile create <name>

New profiles appear in the web UI immediately, since it reads them from disk.

Whether that restriction costs you anything depends on how much you administer your agent from a phone. If you mostly read and chat, it costs nothing — and in exchange the app runs against your real history and real skills, on the same runtime as everything else, with no possibility of becoming a second brain quietly disagreeing with the first.