docs/relay-connector-contract.md
Status: EXPERIMENTAL. This contract MAY CHANGE without a deprecation cycle until at least two real Class-1 platforms (Discord + Telegram) have validated it. Evolution during the experimental phase is additive-only, gated by
contract_version. A breaking change updates both repos in lockstep.
This document is the formal interface between the Hermes gateway (Python,
gateway/relay/) and the connector (Node/TypeScript,
NousResearch/gateway-gateway). The connector implementer's first action is to
read this file.
The gateway runs a generic RelayAdapter that dials out to the connector,
receives a CapabilityDescriptor at handshake, then exchanges normalized
MessageEvents (inbound) and actions (outbound) over a per-turn bidirectional
WebSocket. The gateway never learns which concrete platform is fronting it; the
connector owns all platform-specific socket/identity logic.
connect).handshake(); connector returns a CapabilityDescriptor
(section 2) describing the platform this adapter instance fronts.contract_version (currently 1) is carried in the descriptor. The gateway
ignores unknown descriptor fields (forward-compat) and fills missing optional
fields from defaults.
JSON object. Source of truth: gateway/relay/descriptor.py.
| Field | Type | Required | Meaning |
|---|---|---|---|
contract_version | int | yes | Contract version (additive-only within a version). |
platform | string | yes | Platform name (e.g. "discord", "telegram"). |
label | string | yes | Human-readable label. |
max_message_length | int | yes | Char limit; gateway exposes as MAX_MESSAGE_LENGTH. 0 → treat as 4096. |
supports_draft_streaming | bool | yes | Native draft-streaming preview support. |
supports_edit | bool | yes | Edit-based streaming possible; if false, consumer degrades to one-message-per-segment. |
supports_threads | bool | yes | create_handoff_thread capability. |
markdown_dialect | string | yes | "plain", "markdown_v2", "discord", … (drives supports_code_blocks). |
len_unit | string | yes | "chars" (builtin len) or "utf16" (Telegram UTF-16 code units). |
emoji | string | no | Display emoji (default 🔌). |
platform_hint | string | no | System-prompt platform hint. |
pii_safe | bool | no | Redact PII in session descriptions. |
supports_context | bool | no | Whether the connector can supply surrounding channel/group context for an addressed turn on this platform (Model A on-demand history fetch — Discord/Slack/Matrix; Model B passive buffer — Telegram/Signal/WhatsApp). Default false ⇒ no context is attached to inbound events. See §3. |
supported_ops | string[] | no | Op-level capability discovery: the outbound op names the connector's sender for this platform actually implements (e.g. ["send", "edit", "typing", "follow_up", "get_chat_info"]). Absent/empty ⇒ the connector predates the field and the gateway assumes the legacy op set (send/edit/typing/follow_up); a NEW op is used only when explicitly advertised. |
Most fields are a projection of the gateway's existing PlatformEntry; the
runtime-only fields (len_unit, supports_*, markdown_dialect) come from the
live platform adapter's capability methods.
MessageEvent envelopeThe connector normalizes each platform wire event into a MessageEvent
(gateway/platforms/base.py) and delivers it to the gateway. Inbound is
delivered over the gateway's OUTBOUND /relay WebSocket (see the transport
note below) — the connector pushes an inbound frame down the socket the
gateway already dialed. The gateway keys the session via build_session_key()
from the embedded SessionSource — so populating the right discriminators is
the single highest-correctness responsibility of the connector.
The gateway dials out to the connector's /relay WebSocket for the
handshake + outbound actions (§4) + its own /stop egress (§5). Inbound rides
the same socket in the other direction: the connector pushes an inbound
frame (and interrupt_inbound for §5) down the gateway's outbound WS. There is
no gateway-side inbound HTTP endpoint — a gateway need not (and, when hosted,
cannot) expose any inbound port; everything flows over the connection it
initiated.
Multi-instance routing. The connector instance that owns a platform's socket
(and thus produces inbound events) is generally not the instance the gateway
dialed its outbound WS into. The producing instance therefore publishes the
event on the connector's internal relay bus (Redis pub/sub; RelayBus in
src/core/relayBus.ts) keyed by tenant. Every connector instance subscribes and
routes each message to its local sessions for that tenant
(RelayServer.routeBusMessage); the single instance that actually holds the
gateway's socket delivers it, and instances with no local session for the tenant
no-op. Cross-instance delivery is thus an in-cluster Redis hop, not a public
HTTP call.
Frames (connector → gateway, over the WS):
{"type":"inbound", "event": <MessageEvent>, "bufferId"?}{"type":"interrupt_inbound", "session_key", "chat_id"} (§5){"type":"passthrough_forward", "forward": <PassthroughForward>, "bufferId"?} (§5.1)Channel context on inbound (design relay-channel-context). When the source
platform's descriptor advertised supports_context (§2) and the chat is
multi-party (chat_type ∈ group/channel/thread/forum, never dm), the
connector MAY attach two optional, additive fields to the inbound MessageEvent:
context: an array of read-only surrounding messages (same channel, oldest→
newest) — nearby non-addressed chatter the connector fetched (Model A) or
buffered (Model B). REFERENCE ONLY: it never triggers the agent (the trigger
decision was already made connector-side on the addressed event alone). The
gateway renders it into MessageEvent.channel_context (the same read-only
injection path history-backfill uses).context_error: bool, true when the platform is context-capable but the
fetch/buffer failed and the connector fail-opened to an empty context
(observability marker; surfaced connector-side via the delivery span).Both absent ⇒ byte-identical to today. A connector that never sends them, or a
dm, or a no-context platform, yields no channel_context.
PassthroughForward is the wire form of a forwarded passthrough-plane request
(Class-2/3 webhooks — Discord interactions, Twilio): {platform, botId, method, path, headers: [[k,v],…], bodyB64}. The body is base64-encoded so arbitrary
bytes survive the newline-delimited-JSON transport; the gateway base64-decodes
back to the exact bytes the connector forwarded (the connector already verified
the provider signature and stripped any shared-identity credential at the edge —
§6 — so the gateway re-processes a sanitized, token-free body and acts on it via
the token-less follow_up path). See §3.1.
Trust. The WS upgrade is authenticated with the gateway's per-gateway secret (§6.1), so the channel is trusted end to end — inbound frames are not separately HMAC-signed (the authenticated socket subsumes the per-delivery origin proof the old HTTP path needed). The relay-bus hop is inside the connector trust domain (same as the lease/buffer/capability stores).
Earlier drafts of this contract delivered inbound over a signed HTTP POST to a
gatewayEndpoint(HttpGatewayDelivery+ a gateway-sideinbound_receiver), HMAC-signed with a per-tenant delivery key. That required every gateway to expose a reachable inbound URL — impossible for hosted gateways, which have no public IP. The WS back-channel above replaces it; the per-tenant delivery key is retained at provision for forward-compat but is no longer used for inbound. The passthrough plane (Class-2/3 webhooks like Discord interactions / Twilio) historically still usedgatewayEndpointfor its post-ACK forward; Phase 5 §5.1 moves that forward onto the WS too (thepassthrough_forwardframe above), so a hosted gateway needs zero public inbound surface andgatewayEndpointis retired once the cutover lands.
The passthrough plane answers the provider's latency-critical ACK at the
connector EDGE (e.g. Discord's deferred interaction response within ~3s), then
does a fire-and-forget forward of the real request to the gateway. That
forward needs no response back (the provider was already satisfied), so it rides
the same outbound WS as inbound via a passthrough_forward frame rather than
an HTTP POST. The gateway processes the decoded request through its normal agent
path (a Discord interaction is decoded to a MessageEvent and handled like a
message; the reply egresses over the outbound / follow_up path). bufferId is
present when the forward was buffered (Phase 5 §5.3 buffered-only flip) and the
gateway acks it after durable handoff.
Source of truth: SessionSource.to_dict() in gateway/session.py. These are
every key the gateway accepts on the wire. platform, chat_id, chat_type,
user_id, user_name, thread_id, chat_name, and chat_topic are always
present (may be null); the rest are included only when set.
| Field | Type | Always sent | Meaning |
|---|---|---|---|
platform | string | yes | Platform name (matches the descriptor's platform). |
chat_id | string | yes | Primary conversation id (channel/chat). Session-key discriminator. |
chat_type | string | yes | dm / group / channel / thread / forum. |
chat_name | string|null | yes | Human-readable chat name. |
user_id | string|null | yes | Message author id. Session-key discriminator. |
user_name | string|null | yes | Author display name. |
thread_id | string|null | yes | Thread/forum-topic id when in a thread. Session-key discriminator. |
chat_topic | string|null | yes | Channel topic/description (Discord, Slack). |
user_id_alt | string | no | Platform-specific stable alt id (Signal UUID, Feishu union_id). |
chat_id_alt | string | no | Alternate chat id (e.g. Signal group internal id). |
scope_id | string | no | Platform-neutral scope discriminator: Discord guild / Slack workspace / Matrix server. REQUIRED for Discord/Slack scope isolation. Session-key discriminator. (Canonical name as of the D-Q2.5 wire migration.) |
guild_id | string | no | Legacy alias, no longer read by the connector. As of D-Q2.5c the connector reads and writes only scope_id; the gateway's agent-wide SessionSource.to_dict() still emits guild_id (mirrored to scope_id) for non-relay session persistence, so it may still appear on the wire but the connector ignores it. Do not depend on it. |
parent_chat_id | string | no | Parent channel when chat_id refers to a thread. |
message_id | string | no | Id of the triggering message (for pin/reply/react). |
is_bot(author-is-a-bot/webhook classification) exists on the gateway-side dataclass but is intentionally NOT on the wire in v1 — it is not part ofto_dict(). Do not add it to the connector'sSessionSourceuntil it is first added here and toto_dict()(additive bump).
| Platform | chat_id | chat_type | user_id | thread_id | scope_id |
|---|---|---|---|---|---|
| Discord | channel id | dm/group/thread | author id | thread channel id (threads) | guild id (REQUIRED for server isolation) |
| Telegram | chat id | dm/group/forum | from id | forum topic id (forums) | — |
Get Discord's guild_id wrong and two servers collide into one session.
This is the #1 High-severity risk. The gateway's build_session_key() is the
conformance oracle: for a given SessionSource, the connector's normalization
must produce the same key the Python adapter would. (The Phase-1 stub tests
assert known-input → known-key.)
The envelope carries the originating bot identity as a field distinct from
tenant. Tenant is resolved from the event's own discriminator (Discord
guild_id, Telegram chat_id, webhook path/subdomain) — never from which
token/socket/process delivered it. This keeps one shared bot able to front many
tenants (Phase 6) without overloading an existing field.
Phase 7 adds self-serve, per-user onboarding to a shared bot, which changes which discriminator resolves the instance for a routed inbound message — and adds a management path for users to bind their own account.
Author-first resolution (the multi-tenant-guild rule, D-7.2). A single
Discord guild may hold many tenants — different members each linked to their
own agent. So for delivery the connector resolves the destination instance from
the authenticated author binding (user_instance_binding, keyed by
(tenant, platform, platform_user_id) via resolveByUser), NOT by a
guild→instance route. Concretely:
user_id off the observed event, the
same SessionSource.user_id documented above — never a value asserted by a
gateway or carried in a management frame.This is the per-user_id owner-only routing the connector enforces in
WsGatewayDelivery (the gateway-side multi-tenant-guild E2E driver
gateway_multitenant_guild_driver.py is the cross-repo oracle).
The account-link (DM) path. A user binds their account to an instance with a one-time code, redeemed by DMing the shared bot:
POST /manage/link; instanceId comes from the caller's principal —
a NAS-signed aud=agent:{instanceId} token or the instance's own per-gateway
secret — never the request body)./link <code> as a direct message to the shared bot from
the account they want to bind.user_instance_binding using the authentic
user_id off the observed DM event. From then on, author-first resolution
routes that user's messages to the bound instance.Opt-out is connector-authoritative. Deprovisioning an instance
(POST /manage/deprovision) drops its author bindings (so its users stop
resolving to it) and revokes its per-gateway secret (so its socket can no
longer authenticate — the next WS upgrade is closed 4401). A gateway that
sees a 4401 close after a previously-successful handshake treats it as a
terminal revocation: it stops reconnecting and reports the relay platform as
disabled (not a retryable error). A 4401 before any successful handshake
stays retryable (a cold-start / not-yet-provisioned race, not a revocation).
A scale-to-zero PRIMITIVE (not the behaviour — nothing here decides to sleep or suspends a machine; a later workstream consumes these frames). It lets a gateway enter a drain/idle transition without losing inbound that arrives while it is gone, by making the connector buffer for that instance and replay on reconnect.
Three frames (all keyed by the connection's authenticated per-instance id — read off the stored secret record at the WS upgrade, never asserted in a frame):
{"type":"going_idle"} (gateway → connector) — emitted as part of the
gateway's EXISTING drain transition (the adapter sends it before tearing down
the socket). Asks the connector to flip this instance to buffered-only.{"type":"going_idle_ack"} (connector → gateway) — the connector has flipped:
live delivery has stopped and subsequent inbound for this instance buffers
durably. The gateway stays serving until this ack (so an event landing in
the flip window is delivered live, not lost — the same SUBSCRIBE-before-serve
ordering discipline as the bus). Only after the ack is it safe to close.{"type":"inbound_ack", "bufferId"} (gateway → connector) — durable receipt of
a buffered inbound delivery (which carries its bufferId) replayed on
reconnect. The connector acks the buffer entry only after this, giving
drain-without-dup on the delivery leg: an instance that dies mid-drain
redelivers exactly the unacked tail; an acked entry never redelivers.Buffer + drain. While flipped, the connector appends inbound to a durable
per-instance delivery-leg buffer (delivery:<instanceId>) instead of pushing it
live. On the gateway's reconnect (a NET-NEW reconnect loop re-dials +
re-handshakes after an unexpected close), the new handshake triggers the
connector to drain that backlog over the new socket in order, ack-gated,
then clear the flip so live delivery resumes. This reuses the same
drainWithoutDup machinery as the Discord→connector ingest leg, applied to the
connector→gateway delivery leg. Connector-authoritative throughout: a gateway can
only flip/drain ITS OWN instance.
NOT in scope (deferred behaviour): the autonomous idle timer that DECIDES to drain, the actual machine suspend, and the NAS suspended-health model. The primitive is "when the gateway drains, relay flips to buffered + replays on reconnect, with no loss/dup"; WHAT triggers the drain is out of scope.
The other half of the sleep/wake loop: how a SUSPENDED gateway finds out it has buffered work waiting. A PRIMITIVE — nothing here suspends a machine; it wires the wake SIGNAL so a future scale-to-zero behaviour layer can rely on "buffered ⇒ wake poked."
hermes gateway enroll --wake-url <url> (or
GATEWAY_RELAY_WAKE_URL / gateway.relay_wake_url). Managed/NAS: stamped into
the container env beside GATEWAY_RELAY_URL. Forwarded in the
/relay/provision body as wakeUrl and stored per-instance on the connector's
secret record (gateway-asserted but safely scoped — same posture as
instanceId; the org/tenant stays token-verified, so a gateway can only
register a wake target for ITS OWN instance). DISTINCT from the retired
gatewayEndpoint: a poke target, not a delivery target.wakeUrl, directly (NOT NAS-mediated — relay stays
NAS-independent). It carries no tenant data and no inbound: it only says "you
have buffered work, reconnect." Tenant authority is re-established the normal
way when the gateway re-dials (the authenticated WS upgrade), so a leaked/
guessed wake URL can at worst cause a spurious reconnect of ITS OWN instance.
Rate-limited per instance (one poke per cooldown window, not per event), and
best-effort — a failed poke is swallowed; the gateway still drains whenever it
next reconnects on its own. No new frame: the wake is an out-of-band HTTP GET,
not a relay-WS message (the socket is down — that's the whole point).NOT in scope (deferred behaviour): the actual machine suspend (Fly
autostop:"suspend") and the autonomous idle timer that decides to sleep. The primitive is "buffered event for a sleeping instance ⇒ its wakeUrl gets poked"; WHAT makes the instance sleep (and wake-to-serve) is the behaviour layer.
§3.2 and §3.3 ship the primitives; this section is the contract a separate scale-to-zero behaviour workstream must honour to consume them safely. It owns the decision to suspend, the actual machine suspend, and the platform/health model — none of which live here — but it MUST hold these guarantees, which the primitives assume:
wakeUrl before the instance can ever be suspended. A
suspended instance with no registered wakeUrl is a black hole — buffered
inbound never triggers a poke, so it sleeps through its own traffic until
something else reconnects it. The behaviour layer MUST ensure a reachable
wake target is registered (self-hosted: --wake-url; managed: stamped) as a
precondition of allowing suspend. A wake URL that is unreachable while the
machine is suspended (e.g. points at the suspended machine itself with no
platform autostart in front) is equivalent to none.going_idle → await going_idle_ack BEFORE tearing down the
socket or suspending. Never suspend with an un-acked flip in flight. The
ack is the connector's confirmation that delivery for this instance is now
buffered-only; a machine that suspends after sending going_idle but before
the ack can drop the inbound that races the flip. The gateway already gates
socket teardown on the ack (Q-5.3c); the suspend step MUST sit after a
clean drain completes, not race it.gateway_state running→draining) rather than introduce a
parallel relay-only idle path — the same integration constraint §3.2 places
on going_idle.These are guarantees the behaviour layer OWES the primitives; the primitives owe the behaviour layer only what §3.2/§3.3 already specify (a flip-on-going_idle, a durable per-instance buffer + ack-gated reconnect drain, and a poke on the first buffered event for a flipped instance).
The gateway calls the transport with action dicts. Source of truth:
gateway/relay/transport.py + gateway/relay/adapter.py.
op | Fields | Result |
|---|---|---|
send | chat_id, content, reply_to?, metadata? | {success: bool, message_id?, error?} |
edit | chat_id, message_id, content, metadata? | {success: bool, error?} |
typing | chat_id, content?, metadata? | {success: bool} |
follow_up | session_key, kind, content, metadata? | {success: bool, message_id?, error?} |
send_media | chat_id, media_kind, source_url, content? (caption), filename?, reply_to?, metadata? | {success: bool, message_id?, error?} |
prompt | chat_id, prompt_kind, prompt_id, content (the question), options[]{id,label,style?}, timeout_s?, reply_to?, metadata? | {success: bool, message_id?, error?} |
react | chat_id, message_id, emoji, remove?, metadata? | {success: bool, error?} |
thread_create | chat_id (parent), thread_name, message_id? (anchor), metadata? | {success: bool, thread_id?, error?} |
thread_rename | chat_id (parent), message_id (the THREAD id), thread_name, only_if_current_name?, metadata? | {success: bool, error?} |
get_chat_info(chat_id) is a separate proxied call returning at least
{name, type}.
send_media (Phase 2 media egress). Media crosses the wire BY REFERENCE:
source_url is either (a) a connector re-host the gateway previously
uploaded via POST {connector}/relay/media (raw bytes body, Content-Type +
optional X-Media-Filename headers, per-gateway HMAC bearer — the same token
scheme as the WS upgrade; response {id, size} → reference
{connector}/relay/media/{id}), or (b) a public http(s) URL (e.g. a
fal.media generation) the connector downloads directly. media_kind is one of
image / voice / audio / video / document and selects the
platform-native upload lane (Telegram sendPhoto/sendVoice/…, Discord
multipart attachment, Slack external upload, WhatsApp media upload + media
message). The caption rides content and renders through the platform's
normal markdown lane; platforms without native captions get a follow-up text
send (connector-side). Both routes and the op are gated on supported_ops
advertising send_media — a legacy connector never sees the op (the gateway's
media sends degrade to their pre-media text fallbacks). Size cap 25 MB
(connector mediaStore.ts MEDIA_MAX_BYTES; uploads over it are rejected 413).
Inbound media (Phase 2 media ingress). An inbound event's media_urls
carry fetchable references: platform-public URLs pass through (Discord CDN);
auth-gated/expiring platform URLs (Telegram file API, Slack url_private,
WhatsApp Graph media) are downloaded connector-side with the PLATFORM
credential and re-hosted as {connector}/relay/media/{id} — the platform
credential never crosses the wire. Re-host references are readable by any
authenticated gateway (capability-URL semantics: the id is 128-bit random and
was already delivered to every admitted recipient); the gateway downloads each
reference with its per-gateway bearer and presents LOCAL file paths to the
agent, mirroring native adapters. Re-hosts expire (TTL ~1h) — download on
receipt, not lazily. A parallel media array (same order) adds kind, mime,
size, filename, caption metadata; message_type reflects the first
attachment's kind (image/audio/document).
prompt (Phase 3 interactive). One platform-abstract op renders the
gateway's highest-stakes interactions (exec approvals, slash confirms,
clarify pickers) with NATIVE controls: Discord button components, Telegram
inline keyboards, Slack Block Kit actions, WhatsApp button messages (≤3
options) / list messages (4–10; >10 degrades to the numbered-text fallback).
prompt_kind (approval/clarify/choice) is a styling hint only.
prompt_id is gateway-minted (8 hex) and opaque to the connector; each
option's callback payload carries the token hp1:<prompt_id>:<option_id>
(≤64 bytes — Telegram's callback_data cap binds every lane; option ids are
[A-Za-z0-9_.-], ≤32 chars). style maps per-platform
(primary/success/danger/secondary). timeout_s is advisory on the wire —
expiry is enforced GATEWAY-side (the pending-prompt registry drops expired
entries; a stale press falls through as typed text, mirroring the native
adapters' "approval expired" edit).
prompt_response (Phase 3 inbound). The user's press crosses back as a
normal inbound MessageEvent carrying
prompt_response: {prompt_id, option_id, label?, prompt_message_id?} — never
a bare platform custom_id. The event's text mirrors /{option_id} with
message_type: "command" so a gateway predating the field routes the press
as a typed reply instead of dropping it. The SOURCE is the authentic
CLICKING user (connector-observed: Telegram callback_query.from, Slack
block_actions.user, WhatsApp messages[].from, Discord interaction
member/user), so gateway-side authorization gates apply to a button press
exactly as to a typed /approve. Ingest lanes: Telegram callback_query
(polled, allowed_updates widened; best-effort answerCallbackQuery
spinner-stop), Slack POST /slack/interactions (raw-bytes HMAC + replay
window, same posture as /slack/commands), WhatsApp interactive
button_reply/list_reply (webhook normalize arm), Discord type-3
component interactions (passthrough §5.1 sanitized forward; the type-3 edge
ack is DEFERRED_UPDATE so no visible "thinking…" reply). Foreign
callback payloads (another integration's buttons) never become prompt
events: Telegram/Slack/WhatsApp drop them at the connector; Discord type-3
forwards keep the legacy custom_id-as-text shape.
react (Phase 3 ack lifecycle). Adds/removes the bot's own emoji
reaction on message_id — restoring the native adapters' 👀→✅/❌
processing-lifecycle acks over the relay. Unicode emoji on the wire; the
Slack sender maps to Slack's name vocabulary (eyes, white_check_mark, …)
and treats already_reacted/no_reaction as success (idempotent). Telegram
uses setMessageReaction (empty set = remove; Telegram's curated-emoji
restriction can reject glyphs — the failure is structured and the gateway
treats reactions as cosmetic). WhatsApp sends a reaction message (empty
emoji = remove). Reactions are best-effort by contract: a react failure
must never fail a turn.
thread_create / thread_rename (Phase 4 thread lifecycle). One
platform-abstract pair covers handoff threads, Telegram DM/forum topics, and
LLM-title semantic renames. thread_create: Discord posts a channel thread
(type 11) or a message-anchored thread when message_id is set; Telegram
createForumTopic (topic id returned); Slack posts a NAMED seed root
message and returns its ts (threads there are message-anchored — an
explicit message_id anchor is echoed back verbatim). The created id rides
SendResult.thread_id. thread_rename: Discord PATCHes the thread channel;
Telegram editForumTopic. The only_if_current_name no-clobber guard
is the native adapters' human-rename-wins semantics, enforced
CONNECTOR-side: Discord reads the current name first and no-ops (structured
success:false) on mismatch; Telegram has no topic-name read, so a GUARDED
rename is unsatisfiable and fails safe (unguarded renames proceed). Slack
does not advertise thread_rename (a root message's text is content, not a
name). WhatsApp advertises neither (no threads).
Auto-thread markers + gateway-declared command manifest (Phase 4
inbound/handshake). When the connector's auto-thread egress policy creates
a Discord thread, later inbound events from that thread carry
source.auto_thread_created: true + source.auto_thread_initial_name — the
connector-observed evidence that lights the gateway's semantic-rename lane
(the LLM session title renames the thread via a GUARDED thread_rename;
per-instance memory, so in an N>1 fleet a miss simply never lights the
lane). The gateway may also declare its slash-command set on the Discord
hello frame (command_manifest: [{name, description, options?}]); the
connector reconciles Discord's GLOBAL application-command registration
against it (GET → diff → bulk PUT overwrite; idempotent, debounced,
best-effort — a registration failure never affects the handshake). Commands
still dispatch through the passthrough plane as before; the manifest only
keeps Discord's registry in sync with what the gateway's dispatcher handles.
Inbound reply_to enrichment (Phase 4). A platform reply may carry
reply_to: {text?, author?, is_own?} alongside reply_to_message_id — what
the user QUOTED, populated only from data the connector already had in hand
(Discord's inline referenced_message, Telegram's inline
reply_to_message, WhatsApp context.from + a bounded per-instance
inbound-text cache for the text leg). Absent fields mean the platform didn't
carry the data — never triggers an extra platform API call. is_own = the
quoted message was authored by the fronted bot (same evidence as the
is_reply_to_bot relevance marker). The gateway maps these onto the same
MessageEvent reply-context fields native adapters populate.
typing content? (Slack status clear). A typing frame normally omits
content — the connector renders its platform's active indicator ("is
typing…" Assistant status on Slack, one-shot typing elsewhere). An empty
string content is an explicit clear request: on Slack the connector sets
the Assistant thread status to "", dismissing it. The gateway emits the
clear only for Slack (persistent status); one-shot platforms never receive it.
Additive within contract_version 1, but note the deploy order: a connector
predating gateway-gateway #154 ignores content and would set "is typing…"
on a clear frame — deploy the connector first.
follow_up (A2 capability action). Some inbound payloads carry a credential
that acts on the shared bot identity (e.g. a Discord interaction follow-up
token). Per §6 the connector strips that at the edge and binds it in its
capability vault keyed by the session; it never reaches the gateway. To use
it, the gateway issues follow_up naming the session it is already in
(session_key) plus the capability kind (e.g. discord.interaction_token) —
never a token. The connector resolves the real value from its vault,
enforces the tenant match (tenant B can never wield tenant A's capability), and
egresses. success: false when the capability is absent/expired or the tenant
doesn't match — the gateway has nothing to retry with, by design (a leaked
gateway holds zero capability material). Source of truth:
gateway/relay/transport.py (send_follow_up) + gateway/relay/adapter.py.
/stop) routingsend_interrupt(session_key, reason?) egresses a
mid-turn /stop over the outbound WS. The connector MUST forward it to the
gateway instance running that session_key (the routing invariant).session_key is delivered
as an interrupt_inbound frame down the gateway's outbound WS (§3 transport
note) — routed cross-instance via the relay bus to whichever instance holds
the socket — and bridged by the adapter's on_interrupt(session_key, chat_id)
into the existing per-session interrupt mechanism, cancelling exactly that turn
(siblings untouched).Both directions ride the gateway's outbound WS: the gateway→connector /stop
egresses over it, and the connector→gateway interrupt rides the same inbound
back-channel as a normalized event.
The connector is the sole crypto/identity boundary. The gateway re-validates nothing.
Webhook signatures (Discord ed25519, Twilio HMAC, WeCom BizMsgCrypt) are computed over exact raw bytes, and some payloads are encrypted with a shared secret. The connector fronts a shared bot for many tenants and holds every tenant's platform secrets, so it:
MessageEvent (§3),follow_up),MessageEvent — never the raw signed body.The gateway therefore performs no platform signature/crypto verification on
the relay path; it trusts the normalized event. This is an enforced invariant on
the gateway side (tests/gateway/relay/test_relay_sheds_crypto.py: the relay
package imports/calls no platform-crypto).
Why not "forward the signed body byte-for-byte so the gateway re-validates"? That earlier model is incoherent under an untrusted, disposable tenant gateway:
So byte-preservation is abandoned deliberately: the connector re-serializes the
sanitized event and the gateway trusts it. This also unifies the passthrough and
relay planes — both are "verify at the edge → emit a normalized event," differing
only in transport. See docs/capability-trust-boundary.md (connector repo:
gateway-gateway) for the full A2 rationale and the connector-side vault.
A2 makes the connector the sole holder of platform secrets while the gateway may
be customer-managed and internet-exposed, so the connector⇄gateway channel
is itself authenticated. The gateway holds an enrollment- or provision-issued
per-gateway secret (hermes gateway enroll → connector /relay/enroll, or
managed self-provision → /relay/provision) that authenticates its outbound WS
upgrade. It is an HMAC-SHA256 scheme with a multi-secret rotation verify list
(gateway side: gateway/relay/auth.py; connector side:
src/core/relayAuthToken.ts).
| Leg | Credential | Mechanism |
|---|---|---|
| Gateway → connector WS upgrade | per-gateway secret | An Authorization bearer header on the /relay upgrade. The token is base64url(payload:exp:sig) where payload = gatewayId and sig = HMAC(payload:exp, secret). Connector verifies and rejects the upgrade (close 4401) on mismatch/absence/revocation. The authenticated tenant comes from the connector's store, never the hello frame. |
Connector → gateway inbound (inbound / interrupt_inbound frames) | — (rides the authenticated WS) | Inbound is pushed down the gateway's already-authenticated outbound socket (§3), so no per-message signature is needed. A per-tenant delivery key is still issued at enroll/provision and retained for forward-compat, but is no longer used to sign inbound. |
This is the channel authenticator — distinct from platform crypto, which the
relay path still sheds entirely (§6). The gateway holds zero platform secrets;
the per-gateway secret authenticates only the connector link. Full threat model +
enrollment/rotation/kill-switch design: docs/connector-gateway-auth-design.md
(connector repo).
Phases 1–5 treat the connector as a single-tenant front: inbound events for a tenant fan out to that tenant's gateway socket(s). Phase 6 makes delivery per-INSTANCE — a shared bot can front many users/agents in one tenant (one Discord guild, one Telegram bot) without cross-delivery — and adds a small management plane the agent (or a managed Portal) uses to declare who-sees-what and what's-relevant. All of this lives connector-side; the gateway's only new responsibility is to declare its relevance policy at boot (§7.3).
For each inbound event the connector decides which instances receive it by composing three AND-ed filters. The gateway does not implement these — they run in the connector — but they define the delivery semantics the gateway relies on:
| Layer | Question | Source of truth |
|---|---|---|
| owner / scope ∧ principal | May this instance see this author here? | per-user user_id → instance bindings (the owner floor) + per-instance (guild, channel) scope grants + an owner-only / allow-list / any principal policy. |
| visibility floor | Can the instance's bound owner actually VIEW_CHANNEL this in Discord? | live Discord ACL (effective permissions), fail-closed. Narrows an over-broad scope grant downward. |
| relevance | Given it may see it, should the agent engage? | the relevance policy declared in §7.3 (address-gating / free-response / allow-bots). |
The composition only ever narrows delivery (deliver ⇔ authorized ∧ visible ∧ relevant); the owner floor bypasses the relevance layer (an author's own
message always reaches their own instance — you don't @mention your own agent).
A message authored by an unbound user reaches no instance (fail-closed). The
full design + invariants live in the connector repo
(NousResearch/gateway-gateway); this section is the gateway-facing summary.
The connector mounts authenticated management routes. They share the same
dual-auth as the WS upgrade: either a managed NAS-signed aud=agent:{instanceId}
RS256 JWT, or the gateway's own per-gateway secret bearer (§6.1
make_upgrade_token). In both cases the connector resolves the authoritative
{tenant, instanceId} from its stored record — never from the request
body (a body-asserted instanceId is ignored).
| Route | Purpose |
|---|---|
POST /manage/link | Issue a short-lived code to bind a platform account to the authenticated instance (the /link <code> flow; the connector reads the authentic user_id off the inbound event). |
POST /manage/scope, /manage/scope/release | Claim / release a (guild, channel) scope for the authenticated instance. A channel is owned by at most one instance (non-overlap is a PK constraint). |
POST /manage/principal | Set the instance's principal policy (owner-only | allow-list | any). |
POST /manage/dm-default | Set the user's DM-default instance (DM tie-break when a user linked more than one). |
POST /relay/policy | Declare the instance's relevance policy (§7.3). |
These are connector-owned (the management plane is not part of the gateway's
agent path); the gateway only calls POST /relay/policy (§7.3). The others are
driven by the managed Portal / hermes CLI.
The relevance layer (§7.1) is the per-tenant parity for the gateway's own
behaviour knobs (require_mention, free_response_channels,
{PLATFORM}_ALLOW_BOTS). So the same behaviour governs relay delivery, the
gateway projects those knobs into a platform-agnostic policy and POSTs it to
POST /relay/policy at boot (after its per-gateway secret is resolved).
Body (gateway/relay/__init__.py relay_relevance_policy() → send_relay_policy()):
| Field | Type | Projected from | Meaning |
|---|---|---|---|
platform | string | the fronted platform (relay_platform_identity) | which platform this policy applies to. |
requireAddress | bool | require_mention | a non-owner message must @mention / reply-to the bot to be relevant. |
freeResponseScopes | string[] | free_response_channels | scope (channel) ids where requireAddress is waived. Same scope vocabulary as §7.1's scope grants. |
allowOtherBots | bool | {PLATFORM}_ALLOW_BOTS ∈ {mentions, all} | admit bot-authored messages (default off). |
Auth is the per-gateway upgrade token (§6.1), so the connector attaches the
policy to the authenticated instance. The gateway is the source of truth and
re-declares every boot (a full replace, mirroring the routeKeys upsert at
provision — self-healing). When the projected policy is all-default the gateway
sends nothing (the connector's absent-row default already matches). The POST is
fail-soft: a failure logs and boot proceeds — relevance is an optimization
layered on the authorization gate (§7.1), never a boot dependency. There is no
new gateway inbound surface and no new credential — it reuses the
per-gateway secret and the same host as /relay/provision.
A relevance drop happens before the connector wakes a scaled-to-zero agent (Phase 5), so excluded chatter never spins an agent up — relevance is the primary scale-to-zero lever as well as a correctness filter.
Enterprise deployments configure fronted-platform behavior on the GATEWAY
side, under platforms.relay.extra.<platform> — a supported subset of that
platform's native options. The native platform block (e.g. platforms.slack)
is not read on the relay lane; the connector receives the outcome of these
controls as frame metadata (§4) and executes mechanically — it holds no
platform behavior policy of its own.
platforms:
relay:
extra:
slack:
reply_in_thread: true # default
Resolution: nested extra.<platform> object wins → legacy flat key on
extra honored as fallback → default. Source of truth:
RelayAdapter._effective_reply_in_thread (gateway/relay/adapter.py).
Values coerce exactly as the native Slack adapter's do — 1/true/yes/on
(case-insensitive, whitespace-trimmed) are ON, anything else is OFF — so a
YAML-quoted "false" turns a knob off rather than being read as a truthy
string.
Current controls (Slack):
| Key | Default | Effect |
|---|---|---|
reply_in_thread | true | true: thread-per-message — each top-level DM message anchors its own thread (status, progress, prompts, final reply all carry that metadata.thread_id). false: flat rolling DM — send-lane frames carry NO thread anchor (stripped, not omitted), one shared session per DM. |
dm_top_level_threads_as_sessions | true | Native-parity escape hatch (mirrors platforms.slack.extra.dm_top_level_threads_as_sessions). true: in thread-per-message mode each top-level DM message keys its own session, so concurrent messages run in parallel. false: threaded reply placement is kept but the session stamp is skipped — one rolling DM session (legacy steer/queue posture). No effect in flat mode, which always keeps the single rolling session. |
Typing/status frames always carry the triggering-ts anchor when one is known
(liveliness is unconditional, both modes): Slack's status line is
thread-scoped, and in flat mode the send-side anchor strip guarantees the
status anchor can never leak into reply placement. Semantics of the native
key: see website/docs/user-guide/messaging/slack.md.
Thread-anchor resolution applies to EVERY send lane — text (send) and media
(send_media) alike — through one choke point
(RelayAdapter._apply_slack_thread_anchor). Media frames egress via the same
connector-side Slack sender, which threads on metadata.thread_id only, so an
attachment resolves its anchor identically to a text reply: promoted into
metadata in thread-per-message mode, stripped in flat mode.
Changes take effect on gateway restart; no connector involvement.
contract_version is an int; bump only for additive changes during the
experimental phase (new optional fields, new ops).