Files
hermes-agent/website/docs/user-guide/multi-profile-gateways.md

74 KiB

sidebar_position
sidebar_position
4

Running Many Gateways at Once

Operate multiple profiles — each with its own bot tokens, sessions, and memory — as managed services on a single machine. This page covers the operational concerns: starting them all together, viewing logs across profiles, preventing the host from sleeping, and recovering from common launchd/systemd quirks.

If you only run one Hermes agent, you don't need this page — see Profiles for the basics. And if your instances live on different machines that one desktop app should reach simultaneously, see Connecting Desktop to Many Hermes Instances.

When to use this

You want this setup when you have two or more Hermes agents that should all be online at the same time. Common reasons:

  • A personal assistant on one Telegram bot and a coding agent on another
  • One agent per family member or one per Slack workspace
  • Sandbox + production instances of the same configuration
  • A research agent + a writing agent + a cron-driven bot — each with isolated memory and skills

Every profile already gets its own per-platform supervisor entry: a LaunchAgent (ai.hermes.gateway-<name>.plist), a systemd user service (hermes-gateway-<name>.service), a systemd system service when installed with sudo hermes gateway install --system (runs as the invoking user via User=), a Windows Scheduled Task, or an s6/Docker service — and the Desktop app spawns its own per-profile hermes serve backend. This guide adds the patterns for managing them collectively.

Quick start

# Create profiles (once)
hermes profile create coder
hermes profile create personal-bot
hermes profile create research

# Configure each
coder setup
personal-bot setup
research setup

# Install each gateway as a managed service
coder gateway install
personal-bot gateway install
research gateway install

# Start them all
coder gateway start
personal-bot gateway start
research gateway start

That's it — three independent agents, each on its own process, restarting automatically on crash and on user login.

Alternative: one gateway for all profiles (multiplexing)

The model above runs one process per profile. The alternative is a single multiplexing gateway: one gateway process — whichever profile launched it — becomes the sole inbound process and serves messages for every profile on the box.

The default profile's lifecycle verbs target that process. Named profiles can stop or restart just their own bots without stopping the host:

  • hermes -p <name> gateway run while it is live attaches instead of starting a second process: it prints the host gateway's PID and served set and exits 0. If <name> is not served yet, it asks the host gateway to re-scan profiles/ and attaches once the answer includes it; it refuses (non-zero) only when the host gateway cannot be made to serve it.
  • hermes gateway start --all / restart --all mean the one host multiplexer. They never sweep every gateway process on the box; a profile that still runs its own gateway is reported, never killed, with the hermes gateway migrate --multiplex one-liner.
  • hermes gateway run --replace takes over the process serving this profile, whichever profile launched it. When the host owner is another profile's standalone gateway (an unmigrated per-profile fleet) it never serves this profile, so --replace starts beside it exactly as a plain run does, instead of refusing and respawn-storming under the supervisor. An older Hermes wrote a systemd drop-in (hermes-gateway.service.d/20-replace.conf) that forced --replace onto the unit; hermes update / hermes gateway restart now remove that file. hermes gateway run --force starts a separate gateway without asking the host process at all (the escape hatch when it is wedged or answering wrongly).
  • Under a service supervisor the attach exits 75, not 0 — systemd, s6 and launchd all restart a 75 after a short delay, so the unit keeps retrying and takes over by itself the moment the host process goes away.
  • Two units started at once can both see no host process yet; the host lock decides which one runs, and the loser exits 75 and attaches on the retry. --replace does not skip that check (every generated unit carries it), only --force does.

Multiplexing is on by default (gateway.multiplex_profiles defaults to true), with one safety rule: an unset flag is a request the default gateway settles at boot, never a verdict. Each start it runs the same preflight as hermes gateway migrate --multiplex and multiplexes only when the fold would have been safe — two or more profiles, no secondary still running its own gateway (live process or installed service, or under s6 a per-profile slot that is actually up), no duplicate bot credential, and no port-binding platform without a /p/<profile>/ ingress. Unset means on: when nothing blocks, the gateway multiplexes and writes gateway.multiplex_profiles: true into the default profile's config.yaml (comments preserved) so the file says what the runtime does. Otherwise it comes up serving the default profile only and says so loudly on a host with other profiles: a boxed warning at gateway start naming the profiles that are not served, the blocker, and the fix; the same box in the hermes update summary and hermes gateway status; a banner in the dashboard (/api/status carries multiplex_standalone_reason). A single-profile install is not warned — there is nothing to serve. Nothing is written on a refusal.

An explicit true bypasses migration preflight, except for a launching profile that opts out with gateway.standalone: true:

  • gateway.multiplex_profiles: true (what the migration writes) multiplexes regardless of the preflight — you, or the migration, made the call.
  • gateway.multiplex_profiles has one valid value right now: true, and it is written for you. An unset key resolves on and is made explicit in the default profile's config.yaml. false is retired: the gateway rewrites it to true in place and prints a one-time boxed notice at that start and in the next hermes update summary — never a silent flip. A per-profile gateway is gateway.standalone: true in that profile's own config (a temporary shim, not a supported topology) or --force for the boundary cases below.
  • GATEWAY_MULTIPLEX_PROFILES in the process environment overrides the unset-key decision the same way an explicit true does.
  • gateway.standalone: true in a named profile's own config.yaml (profiles/<name>/config.yaml) is a temporary compatibility shim (see below): the host gateway does not serve that profile, and the profile runs its own gateway without --force. Its gateway serves only itself, even if multiplex_profiles: true is also set (see No new per-profile gateways). Set on the default profile it is ignored with a warning — the default profile is the host gateway. There is no environment variable for this key.

Other processes (hermes -p <name> gateway start, the dashboard, hermes gateway migrate) never guess how an unset flag was settled: they read the running default gateway's served_profiles record, and fall back to the explicit flag only when no gateway runs.

When to prefer multiplexing

  • A container/VPS deployment where N supervisor units, N ports, and N PID files are a burden.
  • Many low-traffic profiles that don't each justify a full process.
  • You want a single thing to start, monitor, and restart.

One-process-per-profile is no longer a topology to choose implicitly: a named profile's gateway install / gateway start refuses without --force (see No new per-profile gateways). A profile that still needs its own gateway while a multiplexing gap is open can set the temporary gateway.standalone: true in its own config.yaml; where a real boundary blocks the fold — a fleet split across UNIX users, or a HERMES_HOME outside <default home>/profiles/ — every profile keeps --force as its path.

Pinning the flag

With the flag unset, the default gateway decides at each boot (above). To pin it, set it on the profile whose gateway runs as the host process (usually the default profile) and restart its gateway — true forces multiplexing even where the boot preflight would have held back (false is retired and ignored):

hermes config set gateway.multiplex_profiles true
hermes gateway restart

Equivalently, in the default profile's ~/.hermes/config.yaml:

gateway:
  multiplex_profiles: true

(The flag is also accepted as a top-level multiplex_profiles: true for convenience.) When multiplexing, the default gateway enumerates every profile, brings up each profile's enabled platforms under that profile's own credentials, and routes each inbound message to the profile it belongs to. Each turn resolves the routed profile's config, skills, memory, SOUL, and provider keys — credentials are never shared across profiles.

The host automatically serves unparked secondary profiles. Use gateway start on a parked profile to bring it back online.

Stopping one profile without stopping the host

For a named profile served by the host multiplexer:

hermes -p coder gateway stop     # park coder; other profiles keep running
hermes -p coder gateway start    # unpark coder and serve it again
hermes -p coder gateway restart  # reconnect coder with its current configuration

stop writes gateway.parked in the profile home before asking the host to stop that profile's adapters and exclude its cron jobs from subsequent ticks. The marker persists across host restarts. Its contents are ignored; an empty file is sufficient. Provisioning can pre-create <profiles-root>/coder/gateway.parked so an installed profile stays offline. Parking does not delete the profile, its sessions, or its scheduled jobs.

start removes the marker, then asks a running host to serve the profile. Without a running host it removes the marker and follows the normal start path; start the host from the default profile if prompted. restart unserves and serves the profile without writing a parked marker, re-reading its config. These operations do not terminate work already dispatched by a cron tick.

The host also rescans every 30 seconds: adding the marker by hand unserves the profile; removing it by hand makes it eligible again. If the control socket does not confirm the request, the CLI says so and the next rescan applies the marker state. Adapter teardown or connection can take additional time. hermes -p coder gateway status reports parked (hermes -p coder gateway start) while the marker exists.

The launch profile cannot be unserved. The default profile's marker is ignored with a warning; its lifecycle verbs and the --all variants retain their whole-host behavior. A separately running --force gateway retains its own process lifecycle.

The dashboard and Desktop Stop / Start buttons for a served profile do the same thing: Stop parks it (/api/gateway/stop?profile=coder spawns hermes -p coder gateway stop), Start unparks it while a host gateway is live, and /api/status lists parked_profiles. Start on a named profile that is not parked still answers 409 — it would need a gateway of its own.

Parked vs gateway.standalone: true

The two never apply to the same profile at the same time, and neither one implies the other:

Profile Host serves it -p X gateway stop -p X gateway start
Served (default case) yes parks it (marker + unserve-profile) already served
Parked (gateway.parked exists) no, until unparked already parked unparks (marker removed + serve-profile)
Standalone (gateway.standalone: true) never stops its own gateway process; writes no marker starts its own gateway

gateway.standalone wins: the host never writes gateway.parked for a standalone profile and never serves it, parked or not, so stop and start on that profile keep their per-process meaning. Parking is the multiplex-native way to take one profile offline — it is what closes the "per-profile stop/restart" gap the temporary shim was kept open for.

No new per-profile gateways

By default, one host gateway serves every profile, and a named profile does not get a gateway of its own. Without the opt-out below, hermes -p coder gateway install (or start, run, and the service step of hermes -p coder setup) refuses with exit 78 whether or not a host gateway is running right now:

❌ Profile 'coder' does not get a gateway of its own.
  Exactly one gateway per host is the inbound process for every
  profile. Starting a separate gateway for this profile would
  double-bind its platforms (two pollers on one bot token, port
  conflicts).

  Install or start the host gateway from the default profile; it serves this one too:

    hermes gateway install

  Or fold an existing per-profile fleet onto one host gateway:

    hermes gateway migrate --multiplex

  A separate per-profile gateway (for a fleet split across UNIX users or a
  HERMES_HOME outside profiles/) needs --force:  hermes -p coder gateway install --force

  Temporary compatibility path while multiplexing gaps are closed: set
  gateway.standalone: true in profiles/coder/config.yaml,
  then wait for the host gateway to rescan (<=30s) or send its rescan-profiles control verb.
  (gateway.standalone is a temporary compatibility shim while multiplexing gaps are fixed;
  it will be removed once they are — plan to fold this profile with `hermes gateway migrate --multiplex`.)

When the host gateway is already running and serves the profile, the first line reads The host gateway already serves profile 'coder'. with the owner's PID and served set, and the pointer is hermes -p default gateway restart. The dashboard's Start button for a named profile returns the same refusal.

Temporary: gateway.standalone: true

:::warning Temporary backwards compatibility, not a topology we keep Multiplex-only is the direction: one gateway per host serves every profile. The switch landed before every gap was closed — the WhatsApp bridge and relay on secondary profiles, and dashboard scoping are the open ones; per-profile stop/start/restart is closed by parking — and fleets that relied on per-profile gateways lost them overnight. gateway.standalone: true exists so those fleets keep working while those gaps are fixed. It will be removed once they are, with a release-notes notice ahead of time; every surface that prints it says so. Do not build new setups on it: if you are starting fresh, run the host multiplexer. If a gap blocks you today, set the key, and file or upvote the issue for the gap so we can remove the shim sooner. :::

Set gateway.standalone: true in the profile's own config.yaml:

# profiles/coder/config.yaml
gateway:
  standalone: true

The host gateway then does not serve the profile, and the boot log records profile 'coder' is standalone (gateway.standalone: true); not served by this gateway. hermes -p coder gateway install|start|run works without --force. If the running host still lists the profile in its served set, the command refuses until it rescans: wait for the next rescan (at most 30 seconds under normal operation), or send the rescan-profiles control verb to the host gateway. No host restart is required.

Removing the key makes the profile eligible for the host again. While the profile's own gateway is live, the host skips adding it and logs that it must be stopped first. Stop that gateway; the host takes the profile on its next rescan.

A standalone profile's adapters, cron, webhook ingress and Kanban notifications run only while its own gateway runs, not under the host multiplexer or hermes serve. Point webhook clients at the standalone gateway's own listener; the host's /p/<profile>/ ingress no longer serves it. The cron destination picker still lists standalone profiles as bot-chat:<name> targets, but the host cannot deliver to those targets.

hermes -p coder gateway status prints standalone by config (gateway.standalone: true) before the profile's own gateway state, and hermes gateway status (default) lists it as standalone by config: coder after the served set. hermes gateway migrate --multiplex leaves the profile alone and prints it as Standalone by config (gateway.standalone: true), left alone. The WhatsApp bridge and relay run in the profile's own gateway, as in any standalone gateway.

--force is not the path for this shim; it remains the escape for the two boundary cases the refusal names (a fleet split across UNIX users, a HERMES_HOME outside profiles/): it installs a real per-profile service, and that service (its ExecStart carries no --force) keeps starting normally afterwards.

What changes when multiplexing is on

Multiplexing changes how a few things behave. None of these apply to a profile that opts out with gateway.standalone: true or runs a separate --force gateway on a blocked host.

1. Secondary profiles must not start their own gateway

With a multiplexer running, a named profile's gateway run attaches to it; gateway install refuses to create another process (exit code 78). The CLI refuses before touching a service manager, preventing a permanently failed systemd unit or a launchd respawn loop. Use the per-profile stop, start, and restart commands above to manage a satellite inside the host. hermes gateway stop on the default profile still takes every served profile offline. The dashboard and Desktop app follow the CLI: for a served profile the "Stop" action parks it and "Start" unparks it (see above); "Start" on an unparked named profile answers 409 with the same explanation (rendered as an inline notice on the System page). "Restart" restarts the multiplexer (the process that actually serves the profile) instead of spawning a -p coder gateway restart that could only fail. Because that restart reconnects every bot on the device, both apps first ask "Restart the shared gateway? All bots on this device reconnect: default, coder, research" (the list is the running gateway's served_profiles) and report "Shared gateway restarted (3 bots)" when it completes. A standalone profile keeps the plain restart. /api/status?profile=coder carries the same list as gateway_shared_with (null for a standalone gateway). "Served" is read from the running gateway's own record (served_profiles in the default home's gateway_state.json), so it stays correct when the multiplexer was enabled only through GATEWAY_MULTIPLEX_PROFILES in the default profile's environment, or when profiles were added after the gateway started.

The setup flows follow the same rule: hermes -p coder setup gateway, hermes -p coder setup, hermes -p coder gateway setup and hermes -p coder import configure the profile's bots but skip the "install the gateway background service" step for a served profile, printing "Profile 'coder' is already served by the default multiplexer" instead of registering a stray unit or plist that could only sit dead. Add the bot token and the running multiplexer picks it up.

The multiplexer is the single inbound process; a second profile gateway would double-bind that profile's platforms. A profile that deliberately wants a separate process opts out with gateway.standalone: true (see No new per-profile gateways); pass --force (accepted by run, start, install and restart) only where a boundary blocks the fold. The cross-profile lifecycle wrapper script earlier on this page is therefore not used in multiplex mode — manage the host or its named profiles directly.

2. HTTP-inbound platforms are reached via a /p/<profile>/ URL prefix

HTTP-inbound traffic for a secondary profile arrives on the default profile's one listener under a profile prefix, not a second port:

# default profile
POST http://host:8644/webhooks/<route>
# the "coder" profile, same listener
POST http://host:8644/p/coder/webhooks/<route>

An unknown or unconfigured profile in the prefix returns 404. The shared listener is the default profile's api_server port (or its webhook port when no API server is enabled); it serves three kinds of profile-prefixed paths:

  • api_server and webhook are mirrored, never duplicated. /p/coder/v1/... and /p/coder/webhooks/<route> are answered by the default profile's own adapter under coder's scope. A secondary must therefore not enable api_server or webhook itself (the dashboard refuses with 409; an API_SERVER_KEY or WEBHOOK_ENABLED in the secondary's .env wires the credential without starting a listener).
  • Every other inbound-port platform runs in shared-listener mode. A secondary that configures Twilio SMS, LINE, Teams, BlueBubbles, Microsoft Graph, WhatsApp Cloud, WeCom callback or Feishu webhook mode gets its own adapter instance built without a port; the default listener forwards /p/<profile>/<the adapter's usual path> to it. See Inbound-port platforms under the multiplexer.
  • WhatsApp (bridge) and Relay are shared ingress owned by the default profile. The multiplexer never starts them for a secondary: WHATSAPP_ENABLED=true in profiles/work/.env does nothing on its own. Enable and configure them on the default profile (their inbound is routed to profiles via profile_routes), or disable them in the secondary. The gateway logs one INFO line per skipped secondary platform, and if no profile runs it a WARNING says the platform is not being served; hermes gateway status --profile work shows whatsapp: not served under multiplex (shared ingress owned by default). The one exception is a profile that opted out with gateway.standalone: true — it runs its own WhatsApp bridge and relay in its own gateway, as any standalone gateway does.

Authentication follows the profile named in the URL. Unprefixed endpoints keep using the default listener's existing credentials.

  • /p/coder/... API-server requests must use API_SERVER_KEY from ~/.hermes/profiles/coder/.env; the default listener key is rejected. Under the multiplexer that key only authenticates the prefix — it does not turn on a second api_server listener in the secondary profile, so you do not need to pin platforms.api_server.enabled: false in the secondary's config.yaml.
  • A webhook route that targets coder must declare profile: coder beside its existing route-specific secret in the default profile's config.yaml. That secret is then accepted only at /p/coder/webhooks/<route> and is rejected on every other profile prefix.
  • Webhook routes without profile remain default-profile routes and are not reachable through a named profile prefix. Dynamic subscriptions bind the same way: hermes webhook subscribe <name> --route-profile coder writes profile: coder into the default gateway's webhook_subscriptions.json and prints the /p/coder/webhooks/<name> URL (hermes webhook ls shows the binding). Use --route-profile, not the global -p coder: -p would write the subscription into coder's own subscriptions file, which the default gateway's webhook adapter never reads.
  • Delivery follows the same binding. A profile: coder route's reply (or deliver_only message) goes out through coder's adapter for the deliver platform, falls back to coder's home channel when deliver_extra.chat_id is unset, and a github_comment delivery runs gh with GH_TOKEN / GITHUB_TOKEN from profiles/coder/.env. If coder has no adapter for that platform the delivery fails (502) rather than posting as another profile's bot; a default route likewise never borrows a platform that is enabled only on a secondary profile.
  • /p/coder/api/platforms/<platform>/events callbacks are verified and dispatched by coder's adapter; when coder has none the callback is a 503.

Named API requests fail closed when the target profile has no API_SERVER_KEY. Security configuration errors remain fatal: for example, an open own-policy platform without GATEWAY_ALLOW_ALL_USERS or its platform-specific allow-all opt-in still aborts gateway startup rather than silently dropping the unsafe profile.

Inbound-port platforms under the multiplexer

A standalone hermes -p coder gateway run binds coder's Twilio, LINE, Teams, … webhook servers on their own ports. Under the multiplexer those adapters are still coder's — same credentials from profiles/coder/.env, same config.yaml, replies sent through coder's channel — but they bind no port. The default profile's shared listener forwards /p/coder/<path> to them, where <path> is exactly the path the adapter would serve standalone. The request is verified by coder's adapter with coder's secret (Twilio auth token, LINE channel secret, Teams app credentials, BlueBubbles password, …) and runs under coder's runtime scope; the default profile's own /path is untouched, and a profile that has no adapter for a path gets 404, never another profile's bot.

Platform Secondary profile's callback URL on the shared listener Verified with the named profile's
Twilio SMS (sms) https://<host>/p/<profile>/webhooks/twilio TWILIO_AUTH_TOKEN signature (SMS_WEBHOOK_URL must be this URL)
LINE (line) https://<host>/p/<profile>/line/webhook (media: /p/<profile>/line/media/...) LINE_CHANNEL_SECRET
Microsoft Teams (teams) https://<host>/p/<profile>/api/messages Bot Framework token for TEAMS_CLIENT_ID
BlueBubbles (bluebubbles) http://<host>/p/<profile>/bluebubbles-webhook (registered with the server automatically) BLUEBUBBLES_PASSWORD
Microsoft Graph (msgraph_webhook) https://<host>/p/<profile>/msgraph/webhook extra.client_state
WhatsApp Cloud (whatsapp_cloud) https://<host>/p/<profile>/whatsapp/webhook WHATSAPP_CLOUD_APP_SECRET / verify token
WeCom callback (wecom_callback) https://<host>/p/<profile>/wecom/callback the app's callback token / AES key
Feishu webhook mode (feishu) https://<host>/p/<profile>/feishu/webhook FEISHU_VERIFICATION_TOKEN / FEISHU_ENCRYPT_KEY

<host> is the public hostname (tunnel, reverse proxy) in front of the default profile's listener; a custom webhook_path in the profile's config moves the path after /p/<profile> accordingly. The gateway logs the exact URL at startup:

[sms] profile 'coder' is served on the default profile's shared listener:
http://127.0.0.1:8642/p/coder/webhooks/twilio (point the vendor's callback URL at this path ...)

and every status surface repeats it, so you know what to paste into the vendor console:

$ hermes -p coder gateway status
✓ Gateway is running via the default-profile multiplexer
  Manage it from the default profile: hermes gateway status

Inbound callback URLs on the shared listener:
  line: http://127.0.0.1:8642/p/coder/line/webhook
  sms: http://127.0.0.1:8642/p/coder/webhooks/twilio

hermes gateway status and hermes status on the default profile list the same URLs per served profile, and the dashboard's Channels page and the Desktop Messaging page show them as each platform's ingress_url when viewing that profile. The default's own api_server and webhook are reported the same way for a served profile — as connected with ingress_url http://127.0.0.1:8642/p/coder/v1 (respectively .../p/coder/webhooks/<route>) — since the profile has no adapter of its own for them; it is the default's listener answering under the /p/coder/ prefix. A per-profile SMS_WEBHOOK_PORT, LINE_PORT, TEAMS_PORT, … in a secondary's .env is ignored under the multiplexer (nothing binds); it applies again the moment that profile runs its own standalone gateway.

3. Per-credential platforms still need their own token per profile

Polling/connection platforms (Telegram, Discord, Slack, Matrix, Signal, …) work fine multiplexed, but each profile that enables one must supply its own bot token — the same token cannot be polled by two profiles at once. If two profiles configure the same (platform, token), the gateway logs an error naming both profiles and parks the duplicate adapter (it shows as fatal / duplicate_credential in runtime status) while the first claimant and every other profile keep running — the gateway itself does not exit. The default profile's adapters connect first and claim their credentials, so the parked adapter is always the secondary's (see Token-conflict safety — the rule is unchanged, it's just enforced inside the one process now).

4. Session keys are namespaced by profile

Each profile's sessions live under an agent:<profile>:… namespace so two profiles on the same platform/chat never collide in the shared session store. The default profile keeps the historical agent:main:… namespace byte-for-byte, so existing default-profile sessions are unaffected — no migration, no orphaned history. Every gateway path that reads a key back — delegation completions after a restart, shutdown notices, a per-user-thread /stop of a sibling's run, /undo, QQ approval buttons — accepts the agent:<profile>:… shape too, so secondary profiles get the same behaviour as the default one. The one profile name that would collide with the default's namespace, a profile literally called main, is keyed agent:main~:… so it keeps its own sessions and its own profiles/main/state.db.

Each profile's rows land in its own state.db: a named profile's under profiles/<name>/state.db, the default profile's under the launch home — even when the write happens inside another profile's routed turn or background tick. The Desktop/TUI backend's own store is likewise pinned to the home it launched under, and a Bot Chat's side agents (prompt.background) persist next to their parent conversation.

5. One PID/lock and one status surface

There is a single process-level PID and lock (the multiplexer, under the default home). hermes status on the default profile reports the multiplexer and lists the profiles it serves (Serves: coder, research). hermes -p coder status and hermes -p coder gateway status report "running via the default-profile multiplexer" instead of "stopped". The dashboard's /api/status?profile=coder / Channels page report the multiplexer as coder's running gateway, with coder's own adapters as its platforms. The single gateway_state.json lives under the default home: secondary adapters appear there as <profile>:<platform> entries beside served_profiles; no per-profile gateway status file is written.

hermes -p coder cron status names the single host gateway and the profiles it serves — Scheduler host: the host gateway (PID 4211) serving profiles default, coder — then checks coder's own ticker heartbeat and last successful tick. A missing or stale heartbeat produces a warning rather than an unconditional running verdict. cron list and cron create also warn when a served profile has no fresh heartbeat. cron status adds tick-failure details that those lightweight checks do not read.

When no gateway owns the host role, cron status tells you to start the one host gateway (hermes --profile default gateway install / gateway run) and to make sure it serves this profile. Installing a per-profile service is shown only under LEGACY (pre-multiplex topology, not recommended): it would start a second gateway process on the host. hermes doctor follows the same rule — under s6 it reports Host gateway: the host gateway (PID 4211) serving profiles default, coder instead of a per-profile slot count, flags any still-supervised per-profile slot as LEGACY, and checks the host systemd unit's linger even when you run doctor from a served profile. The state.db holder lines name the shared host process too, so "3 process(es) holding the DB open" says which gateway and which profiles stopping it would affect.

What does not change

Per-profile .env credential isolation is preserved and, if anything, stricter: a profile's keys are resolved from its own scope and are never unioned into a shared environment. Subprocesses like MCP servers and Kanban workers only ever see their own profile's secrets — including credentials injected by an external secret source (1Password, Bitwarden, …): a stdio MCP server started for profile B receives B's value for such a name, or nothing if B has none, never the default profile's. MCP servers are connected per profile: two profiles that both name a server github with their own token get two connections and each sees only its own tools; profiles whose mcp_servers entry is identical (same route and credentials, including mTLS client_cert/client_key) share one connection, and an owner's /reload-mcp re-registers the sharing profiles' tools without them reloading. auth: oauth servers are never shared across profiles: each profile holds its own token under its own mcp-tokens/ and opens its own connection. Startup connects profiles one after another and, within a profile, at most mcp.discovery_concurrency servers at once (default 4, 0 = unlimited), so a fleet of profiles with many stdio servers no longer spawns every helper process in the same instant. Trust policy stays per profile: a trust: untrusted profile sharing a trust: full profile's connection is still asked before every write-capable call, and supports_parallel_tool_calls applies only to the profile that set it. Terminal settings (terminal.backend, terminal.cwd, terminal.docker_volumes, terminal.docker_shared_container_key, SSH targets, …) are likewise resolved per profile on every routed turn: a profile that omits a terminal key gets the documented default, never the launch profile's value, and a profile whose config.yaml/.env cannot be parsed has terminal execution refused rather than run under another profile's sandbox policy. The media-delivery credential guard (the denylist behind MEDIA: attachments — .env, auth.json, config.yaml, state.db, session transcripts, OAuth token stores) covers every profile under profiles/, so no profile's turn can attach another profile's secrets or chat history to a reply. Authorization is per profile too: GATEWAY_ALLOW_ALL_USERS, GATEWAY_ALLOWED_USERS and every platform allowlist or allow-all opt-in are read from the owning profile's .env — the default profile opting into open access never opens a secondary profile's bot, and a secondary that opts in only in its own .env is honored. The same holds for per-bot behaviour written in a profile's config.yaml (require_mention, mention_patterns, allow_bots, reactions, auto_thread, free_response_auto_thread, dm_policy, ignored_channels, Matrix session_scope, …): a secondary profile's YAML never lands in the shared process environment, so it cannot become the default profile's policy, and the default profile's YAML never governs a secondary bot. The terminal.env_passthrough allowlist, the Yuanbao auto-designated home channel, and the write guards protecting each profile's own config.yaml are resolved per profile as well. Kanban, profile-scoped skills/memory/SOUL, and model routing all behave per-profile exactly as they do with separate gateways.

Outbound identity is per profile too. A turn running for profile P that calls the send_message tool (send, react, media) posts through P's own bot; so do the "Gateway shutting down/restarted" and /update notices for P's sessions, /loop wakeups set from P's chats, and the Discord unauthorized-slash operator alert of P's Discord bot (to P's home channel). If P has no connected bot for that platform the send fails with a clear error — it never falls back to the default profile's bot.

Tool and memory-provider credentials follow the same rule. Hosted OCR (FIRECRAWL_API_KEY), Modal / Browser Use cloud gates, the mem0 OSS OpenAI key, xAI video, and every memory-provider identity (MEM0_USER_ID, SUPERMEMORY_CONTAINER_TAG, RETAINDB_PROJECT, OPENVIKING_ACCOUNT/USER, HINDSIGHT_BANK_ID, HERMES_HONCHO_HOST) are read from the routed profile's .env, so a secondary profile's memories land in its account/bank/project (or the provider's per-profile default), never the default profile's. Custom endpoints travel with their keys — OPENAI_BASE_URL, XAI_BASE_URL, NOUS_INFERENCE_BASE_URL, GATEWAY_PROXY_URL, Firecrawl / Browserbase / RetainDB / Supermemory / Honcho / Hindsight URLs — so a profile's key is never sent to another profile's proxy or self-hosted server. WEIXIN_HOME_CHANNEL, HERMES_LANGUAGE and display.language, and hooks.outbound[].secret_env are likewise per profile, and end-of-session memory extraction for an evicted secondary session runs under that profile's scope.

Per-turn runtime settings follow the routed profile as well: agent.max_turns, fallback_providers, file_read_max_chars, tool_output.*, browser.* timeouts, timezone (including the TZ handed to execute_code sandboxes), the media-delivery policy (gateway.strict, media_delivery_allow_dirs, trust_recent_files*) and the Nous auth.json used for auxiliary calls are all read from the profile serving the turn, never from the profile the gateway was launched under. The same holds for per-profile state files (processes.json, checkpoints/, sandbox snapshot stores, Feishu comment rules/pairing) and for gateway hooks: each profile's hooks/ directory is loaded on its own and fires only for that profile's events. Shell hooks run with the routed profile's HERMES_HOME, without the default profile's secrets in their environment, and their stdin payload carries a profile field naming the profile that fired them.

What is isolated per profile

A quick reference for what a multiplexed turn resolves from its own profile and never shares with the default or any sibling:

Concern Resolved from Behaviour when the profile lacks it
Provider keys, bot tokens, ${VAR} refs in config.yaml The profile's own .env (its secret scope) Unresolved / no adapter — never the default profile's value
Authorization (GATEWAY_ALLOW_ALL_USERS, GATEWAY_ALLOWED_USERS, per-platform allowlists and allow-all opt-ins) The owning profile's .env and config.yaml Closed — a default-profile opt-in never opens a secondary's bot
HTTP endpoints (/p/<profile>/api/..., /p/<profile>/webhooks/..., platform event callbacks) The named profile's API_SERVER_KEY, profile:-bound webhook routes, and its own adapter 401/404; delivery without an adapter is 502/503, never another profile's bot
Inbound-port platforms (/p/<profile>/webhooks/twilio, /p/<profile>/line/webhook, /p/<profile>/api/messages, …) The named profile's own adapter and its secret (Twilio auth token, LINE channel secret, Teams app, BlueBubbles password, …); replies leave through that adapter 401/403 on a wrong secret, 404 when the profile has no such adapter — never the default profile's adapter
Adapter settings (*_REQUIRE_MENTION, *_REACTIONS, *_ALLOW_BOTS, *_PROXY, Discord allow_mentions, Matrix allowed_users / ignore_user_patterns, webhook host/port/URL, Matrix thread/session/E2EE policy, Discord backfill/attachment caps, Buzz reply mode, A2A agent card / public URL, WhatsApp bridge policy, Yuanbao home channel) The owning profile, in this order: explicit .env value → its config.yaml → the adapter's default The adapter's documented default — never the default profile's setting. Single-profile installs keep env-over-YAML exactly as each platform page documents
MEDIA: attachment denylist Every home under profiles/ plus the default home, enumerated at check time A turn can never attach another profile's .env, auth.json, state.db, sessions or token stores
stdio MCP child environment Safe baseline + the profile's scoped values for secret-source names + the server's own env: A name the profile lacks is absent from the child — no default-profile fallthrough
Outbound egress (send_message, shutdown/restart//update notices, /loop wakeups, profile:-bound webhook delivery, github_comment tokens) The profile's own connected adapter and .env Clear failure; never posts through the default profile's bot
Session namespace agent:<profile>:… (default keeps agent:main:…) Two profiles on the same chat never share history
Logs agent.log / errors.log / gateway.log under the profile's own home —
Terminal sandbox settings (terminal.*, SSH targets) The profile's config.yaml Documented default; unparsable config → execution refused
Working directory of a turn (unset terminal.cwd) Same rule as a standalone gateway: $HOME for the local backend, sandbox default otherwise Never the directory the multiplexer process was launched from
Command approvals (command_allowlist, "always" choices) The profile's own config.yaml A default-profile "always" never pre-approves a secondary's command; a secondary's choice is saved to its own config
Sandbox credential-file mounts (terminal.credential_files), security.redact_secrets, browser.* engine/headed flags, lsp.*, auxiliary-provider health marks, logs/mcp-stderr.log The profile's own config.yaml / .env Documented default — never the launch profile's cached value
Cloud-SDK credential clients (Bedrock boto3 clients + model discovery, Azure Entra credential), credential-fetched catalogs (DeepInfra, Copilot context limits, Nous reasoning caps, Ramp Router efforts, xAI / OpenRouter image models, custom-endpoint /models), Camofox VNC address, computer-use aux-vision routing, skill-sync push, remote-backend probe text, learned image token costs, display.skin, guest-mint back-off, banner skills, Yuanbao "active" adapter, Langfuse client The profile's own .env / config.yaml / <home>/cache Documented default — never the launch profile's cached value or its credentials
Session-search knobs (sessions.cjk_fts, sessions.search_slow_ms) The profile's config.yaml Documented default — never the default profile's bridged value
RoomLink capability catalog and the signed execution policy it advertises to a remote Bot (approvals.mode, agent.max_turns, platform_toolsets.api_server) The served profile named by the request (/p/<profile>/v1/room-members/..., the RPC profile param); target_profile is required on every catalog — there is no HERMES_PROFILE fallback Invitation/capabilities fail with the offending target_profile named; a profile that does not exist is refused, never resolved from the launch profile's config
Platform proxies (TELEGRAM_PROXY, DISCORD_PROXY, HTTPS_PROXY, …) The profile's own .env Direct connection — never the default profile's proxy
MCP discovery in the Desktop/dashboard backend Once per served profile home A profile selected after another has already built an agent still discovers its own mcp_servers
Settings changed from a Desktop / TUI session (/busy, /verbose, /approval, /cwd, theme and display toggles) The config.yaml of the profile that owns the session, even when the RPC carries only the session id The session's own profile is written; the launch profile's config.yaml and its TERMINAL_CWD are never touched
MCP connections in the Desktop/dashboard backend and the per-profile cron ticker Keyed per served profile even with gateway.multiplex_profiles off — same rule as the multiplexer A same-named mcp_servers entry with other credentials is its own connection; a served profile never calls a server as another profile
Dashboard actions (hermes -p <name> … spawned by the Desktop/dashboard) A scrubbed child env pinned to that profile's HERMES_HOME The child loads its own .env; the dashboard profile's tokens and ports are not inherited
Every child that acts for a served profile (slash worker, Bot Chat delivery, A2A forward, key_cmd helper, browser driver) That profile's own .env + secret sources over a credential-scrubbed base — with or without gateway.multiplex_profiles (the Desktop/dashboard ?profile= route counts) Absent from the child — a key that reached the launch process only through systemd / Compose / the shell is never inherited by another profile's child
Authorization gates in a child spawned for another profile (*_ALLOWED_USERS / *_ALLOWED_CHANNELS / *_IGNORED_CHANNELS / *_ALLOW_ALL_USERS / *_ALLOW_BOTS, GATEWAY_ALLOW*) — dashboard hermes -p <name> actions, kanban workers, Bot Chat delivery, the post-update per-profile gateway restart The child's own .env / config.yaml, loaded by the child itself Closed (the adapter's documented default) — a gate exported into the spawning process by a unit file or the shell is dropped before the child starts, so profile B never enforces profile A's channel or user list; a same-profile child keeps it
Routed-profile detection in an embedding host that mirrors the served profile into the live HERMES_HOME env var for legacy readers (Hermes WebUI) The launch home the host pinned with hermes_constants.pin_process_hermes_home(); MCP connection keys, the launch-env strip for a served profile's children, the bridged allow-all seed and the terminal.* env-bridge guard all compare against it Without a pin the live env var is the launch home, exactly as before — a host that never mutates HERMES_HOME needs nothing
The launch (default) profile's own credentials in a hermes serve / dashboard process that also serves another profile Its .env + secret sources over the process env frozen the moment the first other profile is served; not re-read afterwards A credential rotated only in the process env (systemctl set-environment, a refreshed op run wrapper that did not re-exec) is not picked up until the process restarts — put rotating keys in .env or a secret source, or restart after rotating
Cron .env tuning (HERMES_CRON_TIMEOUT, HERMES_MODEL fallback, HERMES_CRON_MAX_PARALLEL, prefill file), worker / Bot Chat child env The profile's own .env; children never inherit the default profile's .env settings or bridged TERMINAL_* policy Cron defaults / model refusal, exactly as a standalone hermes -p <name> gateway run
Kanban workers and notifications for a profile's tasks The assignee's .env + config.yaml (toolset pin, terminal backend, media policy, display language) —
/loop ticks, background_process_notifications gate, notice_delivery, background-process checkpoint recovery The owning profile's state.db / config.yaml / processes.json —

What is shared by design: the process, its PID/lock and gateway_state.json (default home), the one HTTP listener, and the profile_routes table (declared on the default profile).

Which profiles are served

gateway.multiplex_profiles: true serves the default profile plus every live named profile under profiles/, except the ones that opted out with gateway.standalone: true in their own config.yaml — a standalone profile runs its own gateway and is not enumerated by the host (see No new per-profile gateways). (The former gateway.multiplex_profile_allowlist key is retired; a config migration removes it from config.yaml, and a profile you do not want served but that has not opted out is archived or deleted instead — hermes profile delete <name>, or move the directory out of profiles/.) Deleted profiles leave a tombstone and are never enumerated; a profile whose directory is gone is never recreated by a served turn, the cron ticker or log routing.

The served set controls /p/<profile>/ API and webhook prefixes, runtime status, profile-route eligibility, and which profiles the in-process cron scheduler ticks (the Desktop backend's ticker re-enumerates the same set on every cycle — a profile created or deleted while Desktop runs joins or leaves the ticked set without a restart — and stands down for any profile a running multiplexer or its own gateway already serves). A multiplexer started as hermes -p <name> gateway run always ticks its own profile's cron store as well.

The served set is live. A profile created while the multiplexer is running (hermes profile create, the dashboard, Desktop or the TUI) is served at once: the creator pings the multiplexer over its control socket, and the multiplexer also rescans profiles/ every 30 seconds as a safety net. The new profile's adapters are built the moment its config.yaml/.env carries a bot token (creators usually create first, then add the token), served_profiles in the default profile's gateway_state.json is updated, and hermes -p <name> gateway status reports it as served — no restart, and the other profiles' adapters and in-flight turns are untouched. Deleting a profile stops and unroutes its adapters the same way, and hermes profile rename unroutes the old name before the directory moves and hot-serves the new one (the old name is not resurrected by the adapters or the cron ticker that were still bound to it). The one-credential-one-poller rule still applies: a hot-added profile that reuses another profile's token is parked with a duplicate_credential error, never started as a second poller.

Routing shared-bot chats to profiles (profile_routes)

Multiplexing selects a profile per credential (each profile's own bot token) or per URL prefix (/p/<profile>/ for HTTP platforms). When several communities share one bot token — for example one Discord bot serving many guilds — you can additionally route specific users/guilds/channels/threads to different profiles with gateway.profile_routes:

gateway:
  multiplex_profiles: true
  profile_routes:
    # An entire Discord server → one profile
    - name: acme-server
      platform: discord
      guild_id: "1234567890"
      profile: acme

    # One channel in that server → a different profile
    - name: acme-support
      platform: discord
      guild_id: "1234567890"
      chat_id: "9876543210"
      profile: acme-support

    # A Telegram group (no guild concept — chat_id only)
    - name: tg-group
      platform: telegram
      chat_id: "-1001234567890"
      profile: tg-profile

    # A WhatsApp DM — write the phone number; JID and LID forms also match
    - name: owner-whatsapp
      platform: whatsapp
      chat_id: "15551234567"
      profile: owner

    # One Teams user across DMs, groups, and channels (exact sender id)
    - name: teams-owner
      platform: teams
      user_id: "00000000-0000-0000-0000-000000000000"
      profile: owner

Routes are matched by additive specificity: user_id = 16, thread_id = 8, chat_id = 4, and guild_id = 2. Thus user_id + chat_id (20) outranks user_id alone (16), which outranks every location-only route (at most 14). All declared fields must hold (AND), equal scores keep declaration order, and a route keyed on a channel also matches threads/forum posts whose parent is that channel. Messages that match no route stay on the default/active profile. The routed profile gets the full per-profile isolation described above (config, skills, memory, credentials, session namespace). Routing works on every platform adapter, not just Discord.

user_id is the sender of the inbound message, compared for exact equality. It is only as trustworthy as the adapter that reports it, so treat it as an authorization input only on platforms whose ingress authenticates the sender. Sender ids are also namespaced per tenant on some platforms — a Slack user id is workspace-local — so on a gateway serving more than one workspace or server, pair user_id with the guild_id of that scope (Discord guild, Slack workspace, Matrix server) rather than relying on the id alone.

Omitting user_id keeps the route unconstrained by sender for backward compatibility. Setting it to null, an empty string, or whitespace invalidates that route instead of broadening it to every sender on the platform.

Sender routing selects a profile; it is not deny-by-default authorization. A sender that matches no route falls through to the default/active profile, exactly like an unrouted channel. To give one person a privileged profile and everyone else a restricted one, declare the privileged sender route first, add a platform-wide catch-all route to the restricted profile after it, and keep the platform's own ingress allowlist in place.

A route applies only to messages received by the default profile's bot unless it names another bot with bot_profile: <profile>. Telegram DMs use the same chat_id for every bot (the user's id), so without this a chat_id route meant for the shared bot would also capture that user's DMs with a secondary profile's dedicated bot. Messages arriving at a secondary profile's own bot stay in that profile:

    # Pin one user's DM with team_b's OWN bot to a third profile
    - name: teamb-owner-dm
      platform: telegram
      bot_profile: team_b
      chat_id: "72719239"
      profile: ops-for-team-b

Authorization for a routed message is always decided by the receiving bot's profile (its token and allowlist), including follow-ups sent while the agent is busy and mid-turn checks such as /topic or /stop; the routed profile itself needs no copy of the allowlist. A routed profile without a bot of its own also receives background notifications (process completions, heartbeats, async delegation results) through the shared bot after a gateway restart.

On WhatsApp and WhatsApp Cloud, a chat_id route matches across user-identity forms: a bare phone number (15551234567), a JID (15551234567@s.whatsapp.net), and a LID (…@lid) all refer to the same person once the bridge has paired them (the same canonicalization session keys and adapter allowlists already use). You can put the phone number in profile_routes and inbound DMs still match whether WhatsApp delivers a JID or a LID. Without a LID mapping yet, the number form still matches a JID (the suffix is stripped) but cannot resolve an unknown LID — that inbound falls through to the default profile until the mapping appears. Group chats (…@g.us) are not sender identities and still match exactly. Telegram numeric ids are unchanged.

profile_routes requires gateway.multiplex_profiles: true; with multiplexing off the routes are ignored. If an explicit route matches but its target profile is not installed (or was deleted), the gateway rejects that ingress and logs the route and target. It does not run the default profile. Traffic that matches no route keeps the historical default-profile behavior.

Cron jobs owned by a routed profile deliver through the shared bot too, but only to targets an enabled route with a chat_id/thread_id maps to that profile (a guild_id + chat_id route qualifies its channel) — a routed profile's job targeting an unrouted chat (or a chat routed to another profile) is never sent through the shared bot. Guild-only routes do not qualify a cron target; add a chat_id route for the delivery channel. Routes declaring user_id do not qualify either: cron has no authenticated inbound sender, so they need a separate location-only route. The routed profile does not need its own platforms.<platform> block for this: the shared bot's authorization comes from the route, not from the satellite's config.

Start, stop, or restart all gateways at once

The CLI ships with single-profile lifecycle commands. To act across every profile, wrap them in a shell loop. Put the snippet below in ~/.local/bin/hermes-gateways and chmod +x it:

#!/bin/sh
set -eu

# Add or remove profile names here as you create / delete profiles.
profiles="default coder personal-bot research"

usage() {
  echo "Usage: hermes-gateways {start|stop|restart|status|list}"
}

run_for_profile() {
  profile="$1"
  action="$2"
  if [ "$profile" = "default" ]; then
    hermes gateway "$action"
  else
    hermes -p "$profile" gateway "$action"
  fi
}

action="${1:-}"
case "$action" in
  start|stop|restart|status)
    for profile in $profiles; do
      echo "==> $action $profile"
      run_for_profile "$profile" "$action"
    done
    ;;
  list)
    hermes gateway list
    ;;
  *)
    usage
    exit 2
    ;;
esac

Then:

hermes-gateways start      # start every configured profile
hermes-gateways stop       # stop every configured profile
hermes-gateways restart    # restart all
hermes-gateways status     # status across all
hermes-gateways list       # delegates to `hermes gateway list`

:::tip The default profile is targeted with hermes gateway <action> (no -p), not hermes -p default gateway <action>. The wrapper above handles both forms. :::

Manage one profile

The shortcut commands every profile installs:

coder gateway run        # foreground (Ctrl-C to stop)
coder gateway start      # start the managed service
coder gateway stop       # stop the managed service
coder gateway restart    # restart
coder gateway status     # status
coder gateway install    # create the LaunchAgent / systemd unit
coder gateway uninstall  # remove the service file

These are equivalent to hermes -p coder gateway <action> — useful if a profile alias is not on PATH or if you target profiles dynamically from a script.

Service files

Each profile installs its own service with a unique name, so installations never clash:

Platform Path
macOS ~/Library/LaunchAgents/ai.hermes.gateway-<profile>.plist
Linux ~/.config/systemd/user/hermes-gateway-<profile>.service

The default profile keeps the historical names: ai.hermes.gateway.plist / hermes-gateway.service.

Viewing logs

Each profile writes to its own log files:

# Default profile
tail -f ~/.hermes/logs/gateway.log
tail -f ~/.hermes/logs/gateway.error.log

# Named profile
tail -f ~/.hermes/profiles/<name>/logs/gateway.log
tail -f ~/.hermes/profiles/<name>/logs/gateway.error.log

Stream every profile's log simultaneously:

tail -f ~/.hermes/logs/gateway.log ~/.hermes/profiles/*/logs/gateway.log

The CLI also has a structured log viewer:

hermes logs -f                  # follow default profile
hermes -p coder logs -f         # follow one profile
hermes logs --help              # filters, levels, JSON output

Identify what's actually running

hermes profile list             # profiles + model + gateway state
hermes-gateways status          # full status across every profile
launchctl list | grep hermes    # macOS — PIDs and labels
systemctl --user list-units 'hermes-gateway-*'   # Linux — units

Editing configuration

Every profile keeps its config inside its own directory:

~/.hermes/profiles/<name>/
├── .env              # API keys, bot tokens (chmod 600)
├── config.yaml       # model, provider, toolsets, gateway settings
└── SOUL.md           # personality / system prompt

The default profile uses ~/.hermes/ directly with the same three files.

Edit them with any editor or via the CLI:

hermes config set model.model anthropic/claude-sonnet-4    # default profile
coder config set model.model openai/gpt-5                  # named profile

After editing .env or config.yaml, restart the affected gateway:

coder gateway restart
# or, for everything:
hermes-gateways restart

Keeping the host awake

The gateway process can run all day, but the operating system will still try to sleep when idle. Two patterns:

macOS — caffeinate

caffeinate is built into macOS and prevents sleep while it runs. No install.

caffeinate -dis                    # block display, idle, and system sleep
caffeinate -dis -t 28800           # same, auto-exit after 8 hours
caffeinate -i -w $(cat ~/.hermes/gateway.pid) &   # awake while default gateway runs

# Persistent: run in background and forget
nohup caffeinate -dis >/dev/null 2>&1 &
disown

# Inspect / stop
pmset -g assertions | grep -iE 'caffeinate|prevent|user is active'
pkill caffeinate
Flag Effect
-d block display sleep
-i block idle system sleep (default)
-m block disk sleep
-s block system sleep (AC-powered Macs only)
-u simulate user activity (prevents screen lock)
-t N auto-exit after N seconds
-w P exit when PID P exits

:::warning Lid-close still sleeps the Mac caffeinate cannot override the hardware-driven lid-close sleep on MacBooks. For lid-closed operation, change your Energy Saver / Battery preferences or use a third-party tool. :::

Linux — systemd-inhibit or loginctl

# Inhibit suspend while a command runs
systemd-inhibit --what=idle:sleep --who=hermes --why="gateways running" \
  sleep infinity &

# Allow user services to keep running after logout (recommended)
sudo loginctl enable-linger "$USER"

After enabling lingering, your systemd user units (including hermes-gateway-<profile>.service) continue running across SSH disconnects and reboots.

Token-conflict safety

Each profile must use unique bot tokens for each platform. If two profiles share a Telegram, Discord, Slack, WhatsApp, or Signal token, the second gateway refuses to start with an error naming the conflicting profile. Under multiplexing the same rule parks only the duplicate profile's adapter and the shared gateway keeps running.

To audit:

grep -H 'TELEGRAM_BOT_TOKEN\|DISCORD_BOT_TOKEN' \
     ~/.hermes/.env ~/.hermes/profiles/*/.env

Migrating from per-profile gateways

If your profiles each run their own gateway today (one systemd unit or launchd agent per profile, from a release before multiplex-only), the default gateway's boot preflight keeps it standalone until they are folded (the unset default never double-binds a running fleet). hermes update folds them for you unless a real boundary blocks it (below); the same fold is one command, and re-running it on a half-migrated host (flag on, a unit left behind, a crash between the two) finishes the job instead of reporting "already multiplexed":

hermes gateway migrate --multiplex --dry-run   # print the plan and any blockers; changes nothing
hermes gateway migrate --multiplex             # apply (asks for confirmation on a TTY; -y skips)

There is no --standalone reverse command: a per-profile fleet is not a supported target. A blocked fleet keeps running as it is, and each profile keeps hermes -p <name> gateway install --force as its path — or opts out of the host gateway with gateway.standalone: true (see No new per-profile gateways), which hermes gateway migrate --multiplex respects.

Docker / Hermes Cloud (s6-supervised container)

Inside the official image every profile has an s6 slot (/run/service/gateway-<profile>). The container's boot registers every named slot down and folds its autostart intent into the root slot, so a fresh boot already multiplexes. An in-place update no longer needs a container restart to converge either: hermes gateway migrate --multiplex (and the hook hermes update runs) parks any named slot that is still up (s6-svc -d plus a down file so a supervisor restart does not revive it), folds its intent into the root slot through the same rule the boot uses, and restarts the root slot. A registered-down slot is never a blocker — only a slot that is actually up is. The one thing the command still cannot do from inside is create a root slot the boot never registered; that case names itself and asks for a container restart.

What hermes update does

After a successful update, when the install has two or more profiles, at least one secondary profile runs its own gateway (a live process or an installed service) and gateway.multiplex_profiles is off, hermes update runs the same preflight:

  • Nothing blocks it → the migration runs automatically (the same code path as hermes gateway migrate --multiplex --yes) and prints what it did. This is deterministic and never prompts, so it also runs on headless/cron updates.
  • Something blocks it → a warning block lists each blocker with its exact fix and the one-liner to run later. Nothing is changed.

Single-profile installs are never migrated (there is nothing to gain), and an install that is already multiplexing is left alone. hermes update also does nothing when no secondary profile runs its own gateway — it never flips modes on an install where nothing was running.

Boundaries hermes update never crosses on its own

The unattended hook only folds profiles that share one UNIX user, one service domain and one profiles/ tree — the shape hermes profile create produces. A standalone secondary behind any of these boundaries stops the automatic path:

boundary example
different service manager or scope default on user systemd, a secondary on system systemd (or launchd), or the default detached with a service-managed secondary
more than one installed unit on a profile a user and a system unit for the same profile (the explicit command removes both)
different UNIX user a system unit with its own User=, or a live gateway owned by another uid; a system unit whose User= this host cannot resolve — on the secondary or on the default — counts as unknown, never as "same user"
HERMES_HOME outside <default home>/profiles/ a unit pinning HERMES_HOME=/opt/hermes/profiles/emma

In that case hermes update prints the boundary it found plus hermes gateway migrate --multiplex, and changes nothing — no unit is removed and the per-profile gateways keep running (--force remains their path where a boundary like these blocks the fold; a profile free of them opts out with gateway.standalone: true). Collapsing such a fleet replaces a kernel-enforced boundary (file ownership, User=) with in-process isolation, which is an operator's decision. The explicit command still makes it: the same findings appear as notices in hermes gateway migrate --multiplex --dry-run so you can read them first, and --multiplex proceeds when you confirm.

Opting out of the automatic migration

Set gateway.auto_multiplex_migration: false on the default profile to keep the automatic fold from ever running on this install:

hermes config set gateway.auto_multiplex_migration false

hermes update then leaves per-profile gateways exactly as they are, with no output and no changes, however eligible the install looks. The setting lives in config, so it survives updates — the decision is made once rather than re-litigated on every release. It is read from the effective config like every other setting, so a value pinned in the managed scope (/etc/hermes/config.yaml) wins over the profile's own file. It governs the automatic path only: hermes gateway migrate --multiplex is an explicit request and still migrates (and is the supported way to opt back in). Absent or true keeps the default behaviour described above.

The explicit command is different: hermes gateway migrate --multiplex with two or more profiles and no standalone secondary gateway still applies the one remaining step — it sets gateway.multiplex_profiles: true and (re)starts the default gateway. You asked for multiplex; you get multiplex.

:::tip Clones do not carry channels hermes profile create --clone leaves the source's bot tokens and allowlists behind (see Profiles → messaging channels are never cloned), so a fleet of clones no longer trips the duplicate-credential blocker below. Older clones that still carry them are flagged by hermes profile list. :::

What the migration does

  1. Stops each secondary profile's standalone gateway and uninstalls its service (systemd user/system unit or launchd agent). What was removed is recorded in ~/.hermes/gateway_migration.json for rollback.
  2. Sets gateway.multiplex_profiles: true in the default profile's config.yaml.
  3. Restarts the default gateway — or installs and starts it on the same service manager the secondaries were using, so a systemd-managed fleet stays systemd-managed.
  4. Waits for the default gateway to record served_profiles covering every profile, then prints a summary.

Blockers and fixes

Blocker Why Fix
Two profiles configure the same platform credential (e.g. the same TELEGRAM_BOT_TOKEN) Under one process a bot token can only be polled once; the multiplexer would park the duplicate and that profile's bot would go silent Remove the token from the second profile, or keep it in default and route that profile's chats with profile_routes
A secondary profile enables a port-binding platform that has no /p/<profile>/ ingress on the default listener The multiplexer skips that whole profile (see rule 2) Disable the platform in that profile (platforms.<name>.enabled: false), or run the profile standalone: set gateway.standalone: true in its own config.yaml and wait for the host to rescan (at most 30 seconds), or send its rescan-profiles control verb. Use hermes -p <name> gateway install --force only where a boundary blocks the fold.

The credential check reuses the gateway's own conflict detection, so its verdict matches what the multiplexer does at startup. Which port-binding platforms have a /p/<profile>/ ingress is read from the adapters themselves (each declares serves_profile_prefix), so the preflight stays correct as new HTTP-inbound adapters gain the prefix.

What changes for inbound-port profiles

A secondary profile that used api_server or webhook on its own port is not blocked — but its URL changes. The preflight prints the exact new URL, for example:

Profile 'coder': api_server moves onto the default listener at
http://127.0.0.1:8642/p/coder/v1/... (its key/secret is unchanged; update
clients that call the old per-profile port).

The profile's own API_SERVER_KEY / webhook secret keeps authenticating the prefixed URL; nothing else about the key changes.

Profiles created after the migration

A profile created while the multiplexer runs is served without a restart (see above). hermes profile create confirms this when the live multiplexer picked the profile up; it prints the hermes gateway restart reminder only when it could not reach the multiplexer (for example, a gateway started from an older build).

Failure handling and resuming

The migration is transactional. Failures it can see coming from the plan (a system unit that would have to run as root without a recorded User=, a config file it cannot rewrite) are refused before any per-profile gateway is stopped. Anything that fails after the manifest is written — the flag write, a later secondary's stop or unit removal, the default's install or start — rolls back through the manifest on the spot, so no profile is left without a gateway. Should the process die anywhere in that window, the next hermes gateway migrate --multiplex sees the flag on, the manifest, and no live multiplexer serving the migrated profiles (an installed but stopped default unit does not count) and resumes from the manifest instead of reporting "already multiplexed". A manifest on disk always means unfinished: it is the resume record, not a rollback command — there is no --standalone reverse, and the compensator above only ever runs inside a single failed apply so that no profile is left without a gateway.

Not covered automatically: s6-supervised containers — they converge on the next container start (the per-profile slots are registered down and the root gateway multiplexes). Windows Scheduled Tasks are folded by the command. The dashboard's System page offers the same migration as a button when the preflight finds an eligible install.

Updating the code

hermes update pulls the latest code once and syncs new bundled skills into every profile:

hermes update
hermes-gateways restart

Running gateways are restarted by the update itself; on an install that still runs one gateway per profile, the update then runs the migration to a single multiplexed gateway — automatically when nothing blocks it, otherwise as a warning naming the boundary (different UNIX user, HERMES_HOME outside profiles/) and the one-liner to run yourself.

User-modified skills are never overwritten.

Troubleshooting

"Could not find service in domain for user gui: 501"

You ran hermes gateway start after a previous hermes gateway stop. The CLI's stop does a full launchctl unload, which removes the service from launchd's registry. The CLI catches this specific error on start and automatically re-loads the plist (↻ launchd job was unloaded; reloading service definition). The service starts normally. Nothing to fix.

Stale PID after a crash

If a profile's gateway shows not running but a process is still alive:

ps -ef | grep "hermes_cli.*-p <profile>"
cat ~/.hermes/profiles/<profile>/gateway.pid
kill -TERM <pid>          # graceful
kill -KILL <pid>          # if that fails after a few seconds
<profile> gateway start

Forcing a hard reset of one service

# macOS
launchctl unload ~/Library/LaunchAgents/ai.hermes.gateway-<profile>.plist
launchctl load   ~/Library/LaunchAgents/ai.hermes.gateway-<profile>.plist

# Linux
systemctl --user restart hermes-gateway-<profile>.service

Health check

hermes doctor                  # default profile
hermes -p <profile> doctor     # one profile