---
sidebar_position: 30
title: "Hermes Relay"
description: "Connect Hermes to messaging platforms through a relay connector that owns the platform credentials — enrollment, capabilities, config, and troubleshooting"
---

# Hermes Relay (Connector)

:::warning Experimental
Relay is **experimental**. The wire contract, auth scheme, and configuration
may change without a deprecation cycle while the system is being validated.
:::

Hermes Relay is not a chat platform itself — it is a **connector system** that
lets your gateway front one or more real messaging platforms (Discord,
Telegram, Slack, WhatsApp, …) **without holding any platform credentials**. A
separate service, the *connector*, owns the platform bot tokens and sockets.
Your gateway dials **out** to the connector over a single authenticated
WebSocket, receives a capability descriptor at handshake, and then exchanges
normalized message events (inbound) and actions (outbound) over that socket.

Key properties:

- **Outbound-only networking.** The gateway never opens an inbound port.
  Inbound messages ride back down the same WebSocket the gateway dialed, so
  relay works behind NAT and on hosts with no public IP.
- **No platform secrets on the gateway.** Bot tokens live on the connector.
  Auth-gated platform media URLs are re-hosted connector-side, so platform
  credentials never cross the wire.
- **Platform-agnostic.** The gateway learns what the fronted platform can do
  (message length limits, markdown dialect, edit/thread/streaming support, and
  the exact set of supported operations) from the handshake descriptor, not
  from hardcoded platform logic.

The formal gateway ⇄ connector interface lives in the repository at
`docs/relay-connector-contract.md`.

## When to use Relay

Relay is for deployments where a hosted or shared connector service manages
the platform side — for example multi-tenant hosting where one shared bot
fronts many users' agents, or setups where you don't want bot tokens on the
gateway machine. If you run your own bots directly, use the native platform
adapters ([Telegram](/user-guide/messaging/telegram),
[Discord](/user-guide/messaging/discord), etc.) instead.

## Enrollment

A self-hosted gateway authenticates to the connector with a per-gateway
secret. `hermes gateway enroll` redeems a **single-use enrollment token**
(minted by the connector when your tenant's route is provisioned and delivered
with your gateway config) for that secret:

```bash
hermes gateway enroll \
  --token <enrollment-token> \
  --connector-url wss://connector.example.com/relay
```

What it does:

1. Resolves a fresh Nous Portal access token from your existing login
   (`~/.hermes/auth.json`) — this proves which Nous org (tenant) you own. If
   `gateway.idp.token_url` is configured, your own IdP is used instead (the
   air-gapped / self-hosted-IdP path, no Nous Portal involved): with
   `client_id`/`client_secret` configured it performs a generic OAuth2
   client-credentials grant; with neither configured the URL is treated as an
   ambient token endpoint (plain GET whose response body is the token — the
   metadata-server pattern, e.g. Domino's `$DOMINO_API_PROXY/access-token`).
   Configuring only one of the two credentials is an error.
2. POSTs the enrollment token and a gateway id to the connector's
   `/relay/enroll` endpoint over TLS.
3. The connector verifies the token (signature, single-use, tenant match),
   mints a per-gateway secret plus a per-tenant delivery key, and returns them
   once.
4. Persists the credentials into `~/.hermes/.env`:
   `GATEWAY_RELAY_ID`, `GATEWAY_RELAY_SECRET`, `GATEWAY_RELAY_DELIVERY_KEY`
   (plus `GATEWAY_RELAY_URL` / `GATEWAY_RELAY_WAKE_URL` when supplied).

Restart the gateway afterwards to pick up the new environment.

Flags:

| Flag | Description |
|------|-------------|
| `--token` | The single-use enrollment token. Also settable via `GATEWAY_RELAY_ENROLL_TOKEN`. |
| `--connector-url` | Connector base or relay URL (`wss://…/relay` or `https://…`). Also settable via `GATEWAY_RELAY_URL` or `gateway.relay_url` in `config.yaml`. |
| `--gateway-id` | Stable id for this gateway instance (used for kill-switch granularity). Defaults to `gw-<hostname>`. |
| `--wake-url` | Optional reachable URL the connector pokes (payload-free GET) to wake this gateway when buffered work arrives while it is idle. Persisted as `GATEWAY_RELAY_WAKE_URL`. Without it the gateway still drains buffered messages whenever it next reconnects. |

:::note Managed installs
`hermes gateway enroll` refuses to run in managed/hosted installs — there the
hosting platform provisions the relay secret directly into the container
environment.
:::

## Configuration

Relay activates when a connector relay URL is configured — there is no
separate feature flag. Deployments that don't set it are unaffected.

| Setting | Where | Meaning |
|---------|-------|---------|
| `GATEWAY_RELAY_URL` | env (`~/.hermes/.env`) | Connector relay WebSocket URL. Presence enables the relay platform. |
| `gateway.relay_url` | `config.yaml` | Same as above, config-file form (env takes precedence). |
| `GATEWAY_RELAY_ID` | env | This gateway instance's id (written by `enroll`). |
| `GATEWAY_RELAY_SECRET` | env | Per-gateway secret authenticating the WebSocket upgrade (written by `enroll`). |
| `GATEWAY_RELAY_DELIVERY_KEY` | env | Per-tenant delivery key (written by `enroll`; retained for forward-compat). |
| `GATEWAY_RELAY_WAKE_URL` / `gateway.relay_wake_url` | env / `config.yaml` | Optional wake-poke target for idle/suspended gateways. |
| `GATEWAY_RELAY_PLATFORMS` | env | Comma-separated list of platforms this gateway fronts over one connection (e.g. `discord,telegram`). Usually stamped by the deployment/orchestrator. |
| `GATEWAY_RELAY_BOT_IDS` | env | JSON map of per-platform bot identities, e.g. `{"discord": {"botId": "…"}}`. Paired with `GATEWAY_RELAY_PLATFORMS`. |
| `gateway.idp.token_url` | `config.yaml` | When set, enrollment/provisioning authenticates against your own IdP instead of Nous Portal: OAuth2 client-credentials when `gateway.idp.client_id`/`client_secret` are also set; otherwise an ambient token endpoint (plain GET returning the token, raw or `{"access_token": …}`). |

## Supported capabilities

What actually works over a relay connection is negotiated at handshake: the
connector advertises a `supported_ops` list, and the gateway only uses an
operation the connector explicitly advertises (older connectors fall back to a
legacy `send`/`edit`/`typing`/`follow_up` set). Per-platform capability flags
(edit-based streaming, threads, draft streaming, markdown dialect, message
length limit) also come from the handshake descriptor. Subject to that
negotiation, the relay supports:

- **Text messages and streaming** — sends, replies, and progressive
  edit-based streaming when the fronted platform supports message editing;
  otherwise output degrades to one message per segment.
- **Media, both directions** — outbound images, voice, audio, video, and
  documents are uploaded to the connector (or referenced by public URL) and
  delivered through each platform's native upload lane, with captions.
  Inbound attachments are localized to files for the agent; auth-gated
  platform URLs are re-hosted connector-side so platform credentials never
  reach the gateway. Media re-hosts are capped at 25 MB and expire (~1 hour).
- **Native interactive prompts** — exec approvals, confirmations, and clarify
  questions render with **native platform controls** (Discord buttons,
  Telegram inline keyboards, Slack Block Kit actions, WhatsApp button/list
  messages) instead of numbered-text fallbacks. Button presses come back as
  authenticated prompt responses from the actual clicking user, so the
  gateway's authorization gates apply exactly as to a typed reply. Prompt
  expiry is enforced gateway-side.
- **Reaction ack lifecycle** — the bot's processing-status reactions
  (👀 while working, ✅/❌ on completion) work over the relay. Reactions are
  best-effort: a failed reaction never fails a turn.
- **Thread lifecycle** — creating handoff threads and renaming threads
  (including LLM-titled semantic renames) through platform-abstract
  `thread_create` / `thread_rename` operations, with a no-clobber guard so a
  human's manual rename wins. Availability depends on the platform (e.g.
  Slack threads can't be renamed; WhatsApp has no threads).
- **Typing indicators** — the gateway egresses typing (and stop-typing) through
  the connector while processing.
- **Chat metadata** — `get_chat_info` lookups are proxied to the connector
  when advertised.
- **Buffered delivery and wake** — when the gateway goes idle or disconnects,
  the connector buffers inbound messages durably and replays them in order on
  reconnect (ack-gated, no loss or duplication). If a wake URL is registered,
  the connector pokes it when buffered work arrives for a sleeping gateway.

Multi-platform fronting is supported: one gateway can front several platforms
(e.g. Discord *and* Telegram) over a single relay connection, with each
outbound message tagged for the platform it targets.

## Troubleshooting

**Enrollment fails with 401** — the connector could not verify your identity
token. Re-login with `hermes auth add nous` (or `hermes setup`) and retry.

**Enrollment fails with 403** — the enrollment token is invalid, expired,
already used, or belongs to a different tenant. Enrollment tokens are
single-use; request a fresh one from whoever provisioned your tenant route.

**"Could not reach the connector"** — check the connector URL. You can paste
either the `wss://…/relay` dial URL or the `https://…` base URL; the CLI maps
between them automatically.

**`enroll` refuses to run** — you are in a managed/hosted install, where the
relay secret is provisioned by the hosting platform. Self-enrollment is only
for self-hosted gateways.

**Relay platform shows as disabled after it previously worked** — a WebSocket
close with code 4401 *after* a successful handshake means the gateway's secret
was revoked (e.g. the instance was deprovisioned). The gateway deliberately
stops reconnecting and reports relay as disabled rather than retrying. A 4401
*before* any successful handshake is treated as a transient
not-yet-provisioned race and retried normally.

**Nothing changed after enrolling** — the gateway reads `GATEWAY_RELAY_*` at
startup. Restart it (`hermes gateway restart`).

**A feature (buttons, media, threads…) silently degrades to plain text** — the
connector for your platform did not advertise that operation in its handshake
`supported_ops`. The gateway intentionally falls back to the text behavior
rather than sending an op the connector can't handle.
