This release is a pre-release and may not be stable for production use.
Relay-Hermes
Relay-Hermes connects Hermes Agent to Relay as an always-on messaging platform.
Relay events arrive over the Relay v1 acknowledged WebSocket. Hermes replies are sent through Relay's REST API.
Delivery model
For each Relay event, the plugin:
- commits the complete envelope and
event_idto a durable SQLite inbox; - sends a cumulative WebSocket ACK only after the commit;
- deduplicates replayed
event_idvalues; - starts a Hermes turn only for an inbound
message.receivedevent; - explicitly marks the Chat Read at intake, as soon as the message is accepted for Hermes (before Hermes decides whether it starts a new turn or folds the message into one already running);
- sends each Message through
POST /v1/chats/{chatId}/messageswith anIdempotency-Key.
Transport acknowledgement and Read are separate. A WebSocket ACK never marks a Chat Read.
Relay-Hermes does not poll for events and does not expose a public HTTP server.
Recovery
The SQLite inbox survives gateway restarts. If processing fails, the event
returns to pending; the durable transport checkpoint does not move backward.
The state directory and SQLite database are bound to one Relay account using
the normalized API origin and a one-way Agent Token fingerprint. The token is
never written to state. Changing the token or API origin while reusing a state
directory fails before startup requeues work or reads a FULL-sync snapshot.
On supported Linux systems every directory component and the database are
opened through pinned descriptors with no-follow checks before SQLite receives
an already-open file descriptor path. SQLite never connects to the mutable
configured pathname and cannot create through a dangling database symlink or a
replaced directory. The directory is forced to mode 0700, including when it
already exists. Use a separate RELAY_STATE_DIR for every staging or
production Agent.
Pre-binding databases are not adopted automatically; move one aside only after
accounting for its pending work.
If Relay reports that a checkpoint is outside retention, the plugin follows
the WebSocket FULL-sync flow. It pages through visible Chats and Messages,
validates the snapshot, atomically stores it with full_sync_through, and
sends full_sync_complete only after the transaction commits. Historical
Messages rebuild local indexes and never become new Hermes turns.
What works
- Typing indicators while Hermes works.
- Reactions in both directions.
- Tool-approval answers. Reply exactly
/approve,/approve session,/approve always, or/deny. message.failedevents are logged, not rejected.
Edits and unsend are not supported.
Install
Supported Hermes versions are 0.19.0 or newer, tested through 0.21.3. A newer minor version warns and runs. Install Hermes itself from upstream source, not a wheel.
Connect with the Relay CLI, then start the gateway:
npx relaymessenger@staging connect hermes
hermes gateway run
The CLI installs and enables the plugin. It writes the connection settings
to <HERMES_HOME>/.env and offers to start the gateway for you.
To install the plugin manually instead:
hermes plugins install RelayMessenger/Relay-Hermes --enable
Then set the connection settings below in <HERMES_HOME>/.env.
An Agent using this WebSocket must not have a saved webhook subscription.
Relay rejects the WebSocket upgrade with HTTP 409 until those subscriptions
are removed.
For an always-on installation, use Hermes's service commands:
hermes gateway install
hermes gateway start
The Hermes platform id is relayapp. Hermes already reserves relay for its
generic connector platform.
Configuration
HERMES_HOME selects the Hermes home directory. It defaults to ~/.hermes.
The CLI writes these settings to <HERMES_HOME>/.env:
RELAY_AGENT_TOKENis the required Agent Token.RELAY_BASE_URLis the agent's API origin. The adapter default ishttps://api.relayapp.im.RELAY_STATE_DIRis the durable inbox directory, set to<HERMES_HOME>/relay.RELAY_ALLOWED_CONTACTSis an optional comma-separated list of Contact ids allowed to start turns. The CLI writes it only when set; unset allows all reachable Contacts.
Additional adapter settings:
| Setting | Default | Meaning |
|---|---|---|
RELAY_REPLY_TO_MODE |
auto |
Reply anchor policy: off, first, all, or auto |
RELAY_GROUP_CHAT_POLICY |
mentions |
Group Chat policy: mentions or all |
RELAY_HOME_CHAT |
unset | Chat id for cron and direct hermes send delivery |
RELAY_HOME_CHAT_NAME |
Chat id | Human label for the home Chat |
RELAY_BASE_URL must be an HTTPS origin, except that HTTP is accepted for
loopback development. A configured invalid value fails closed and is never
replaced with the production default.
Slash commands work like on Telegram. To restrict who may run them, set
allow_admin_from (and optionally user_allowed_commands) under
gateway.platforms.relayapp.extra in Hermes's config, using Relay Contact ids.
Display defaults
Hermes keeps a built-in table of per-platform display defaults
(gateway/display_config.py); its iMessage adapters (bluebubbles,
photon) sit in the quiet tier there, with tool progress, interim
commentary, heartbeats, and streaming previews off, because those inboxes
cannot edit a message once sent. A plugin cannot add a row to that table, so
relayapp inherits the global defaults, which were written for platforms
that edit in place.
The adapter itself refuses tool-progress lines (format_tool_event returns
None). Everything else is a user setting. To read like iMessage, use these Hermes
display settings. An unset key falls through to the global display.<key>:
display:
platforms:
relayapp:
tool_progress: off
interim_assistant_messages: false
long_running_notifications: false
streaming: false
busy_ack_detail: false
tool_preview_length: 0
display.busy_input_mode (default interrupt) is global, not per platform,
and is left alone: a message that arrives while Hermes is mid-turn is folded
into that turn, queued behind it, or (in queue mode) buffered for 0.35 s
and then queued. The adapter marks it Read at intake and leaves the reply
unquoted, so it reads as it does on iMessage. Its durable inbox row is
settled only when the session goes quiet: at the successful end of the turn
that consumed it, unless Hermes still holds it for a later turn, in which
case that later turn settles it. If the gateway dies before then the row
replays on restart, which is the safe side.
Hermes core posts a busy acknowledgement bubble when a message lands
mid-turn ("Redirected current run", "Interrupting current task"); its iMessage
adapters show the same bubble, and this plugin does not suppress it. Hermes's
own environment variable turns it off for every platform
(gateway/run_busy.py: os.environ.get("HERMES_GATEWAY_BUSY_ACK_ENABLED", "true").lower() != "true" suppresses the ack):
export HERMES_GATEWAY_BUSY_ACK_ENABLED=false
Relay resolves the token, API origin, chat allowlist, state directory, and
delivery settings through Hermes's shared adapter credential reader. The primary
profile uses its own process environment during unscoped startup. A secondary
profile's installed secret scope remains authoritative: a missing value never
falls through to the primary profile's process environment. The default state path is under the active profile home,
so profile inboxes are distinct even when neither profile sets
RELAY_STATE_DIR.
Isolated staging
Use a staging Agent Token, the staging API origin, and a separate inbox:
export RELAY_AGENT_TOKEN='staging-agent-token'
export RELAY_BASE_URL='https://api.staging.relayapp.im'
export RELAY_STATE_DIR="${HERMES_HOME:-${HOME}/.hermes}/relay-staging"
./scripts/run-staging.sh
The staging helper refuses every other API origin.
Locked Relay contract
Current contract validation is pinned to Relay Server developer OpenAPI commit
4fe4a71e20f4f51e5f8db5714c985687c8a63d89. The exact
contracts/developer/openapi.yaml SHA-256 is
46eeedd5a5e99e879e32c45972799364021143df9f81acd60837713210639735.
Those exact public bytes are checked in at
contracts/relay-server/4fe4a71e20f4f51e5f8db5714c985687c8a63d89/openapi.yaml;
normal CI and RC publication validate that local snapshot without private
repository credentials.
The runtime contract used here is:
GET /v1/websocketfor acknowledged agent events;GET /v1/chatsandGET /v1/chats/{chatId}/messagesonly for a server-directed FULL sync;POST /v1/chats/{chatId}/readwith no request body at intake;POST /v1/attachments, followed by the allocated rawPUT, for local files;POST /v1/chats/{chatId}/messageswithIdempotency-Keyfor every Message send;- event envelopes with
api_version: v1andwebhook_version: 2026-08-30.
Relay vocabulary in the adapter is Contact, Handle, Chat, Message, and Participant.
Releasing
Nothing is published by hand. There are two lanes, the same two Relay-SDK uses for npm, in PEP 440:
- Every merge to
stagingpublishes a development build. ThePublish staging to PyPIworkflow decides the next version from what PyPI holds, commitsrelease: version the staging package automaticallytostaging(it rewritespyproject.tomlandplugin.yaml, nothing else), runs the full CI at that commit, and uploads the wheel and sdist. The version is the target with a.devNsuffix:1.0.0rc3.dev0,1.0.0rc3.dev1, and so on while the target is the candidate1.0.0rc3;1.0.0.dev0once the target is1.0.0. - A promotion is a pull request from
stagingtomain, merged without a merge commit, somainis always a commitstagingalready has. TheRelease to PyPIworkflow onmainstrips.devN, publishes the target (1.0.0rc3, later1.0.0), reads it back from PyPI, and records the tagv1.0.0rc3. A target PyPI already has is skipped, so a second promotion of the same tree publishes nothing.
pip install relay-hermes never selects a .dev release. pip install --pre relay-hermes does, and pip install relay-hermes==1.0.0rc3.dev0 names one
exactly. plugin.yaml carries the same version in its manifest form:
1.0.0-rc.3.dev.0 for 1.0.0rc3.dev0, 1.0.0-rc.3 for 1.0.0rc3,
1.0.0-dev.0 for 1.0.0.dev0.
To aim at a different target, edit the version in pyproject.toml (and the
manifest form in plugin.yaml) in a normal pull request to staging:
1.0.0 or 1.0.0.dev0 ends the candidate line and starts the GA line,
1.1.0 starts a minor. The staging lane keeps a hand-written unpublished
version as written and carries on from there. The rules are the table at the
top of scripts/release_version.py, and every pull request rehearses both
lanes against live PyPI without publishing (Rehearse the staging bump and
Rehearse the release in CI).
Credentials
Both lanes check the distributions through one shared step
(.github/actions/check-dist) and then upload with the same two steps, one
per credential. Which one runs is the repository variable
PYPI_TRUSTED_PUBLISHING:
- unset (today): the project-scoped
PYPI_API_TOKENsecret, attestations off (PEP 740 attestations only work through Trusted Publishing); true: PyPI Trusted Publishing over OIDC, no token, attestations on.
To switch, register two trusted publishers on the relay-hermes project
(PyPI: project settings, Publishing, GitHub), one per lane. Every field is
exact:
| Field | Staging lane | Release lane |
|---|---|---|
| Owner | RelayMessenger |
RelayMessenger |
| Repository name | Relay-Hermes |
Relay-Hermes |
| Workflow name | publish-staging.yml |
release.yml |
| Environment name | pypi-staging |
pypi-release |
Then set the repository variable PYPI_TRUSTED_PUBLISHING to true
(GitHub: Settings, Secrets and variables, Actions, Variables). No file
changes. The two workflow names are the workflows that call the upload
action directly, which is what PyPI matches; a reusable workflow cannot be a
trusted publisher, and the upload action cannot run from inside a composite
action. The pypi-staging and pypi-release environments are
where any approval rule goes; the older pypi-rc environment belongs to the
manual Publish release candidate workflow, which stays until the two lanes
have each published once and is then removed.
Development
Python 3.11 through 3.13 are supported.
python -m venv .venv
.venv/bin/pip install -e '.[dev]'
HERMES_AGENT_SRC=/path/to/hermes-agent .venv/bin/python -m pytest
python -m build
hermes plugins doctor . --ci
Tests cover REST paths and bodies, Message idempotency, explicit Read timing, FULL-sync recovery, WebSocket authentication and acknowledgement ordering, replay deduplication, heartbeat and reconnect behavior, package installation, and Hermes directory-plugin registration.
Release files for relay-hermes 1.0.0rc6.dev7
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| relay_hermes-1.0.0rc6.dev7.tar.gz | 101.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| relay_hermes-1.0.0rc6.dev7-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 155.3 kB
Release files / relay_hermes-1.0.0rc6.dev7.tar.gz
| Download URL | relay_hermes-1.0.0rc6.dev7.tar.gz |
|---|---|
| Size | 101.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bdd0e53a6aa22c6d7e25d055bc11d0c044b0c45b8e5fe0581748c92e29c84c29
|
|
BLAKE2b-256 checksum How to use checksums |
b1abfb287fbcf0143e80f24bb2f099e48bfc259021f2c0e475a14153b82df1c6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / relay_hermes-1.0.0rc6.dev7-py3-none-any.whl
| Download URL | relay_hermes-1.0.0rc6.dev7-py3-none-any.whl |
|---|---|
| Size | 53.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2ee4c41488ab4ee265dc21d72c512901305b8ff20d9f9de9f805747c65da8434
|
|
BLAKE2b-256 checksum How to use checksums |
d51076c7e487f33eb3021fa2a98edf1213e2da702109b09d827e1db47f5299be
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|