hermes-agent/docs/profile-routing.md
Teknium 647520f83e fix(gateway): gate profile routing on multiplex_profiles + widen batch-key routing to all adapters
Follow-ups on the salvaged #20096 profile-routing feature:

- _profile_name_for_source now returns None unless gateway.multiplex_profiles
  is on. Routing stamps source.profile, which namespaces session/batch keys,
  but the profile-scoped agent run only activates under multiplexing — without
  the gate, configured routes with multiplexing off split batch/session keys
  into agent:<profile> while the agent still ran from agent:main.
- Widen the profile-aware _text_batch_key fix from Discord to every adapter
  that builds batch keys via build_session_key (telegram, whatsapp, matrix,
  feishu, wecom, weixin) — routing is platform-generic, so the batch-key
  namespace fix must be too.
- Downgrade the no-route-matched log from INFO to DEBUG (fired on every
  unrouted inbound message).
- GatewayConfig.to_dict(): serialize profile_routes as plain dicts
  (ProfileRoute dataclasses are not JSON-safe).
- Docs: correct the 'independent of multiplexing' claim in
  docs/profile-routing.md (routing requires multiplexing), fix the
  platform-only specificity row (0, not 1), and document profile_routes in
  website/docs/user-guide/multi-profile-gateways.md.
- Tests: pin the multiplex gate (routes ignored when off, active when on,
  build_source end-to-end stays in agent:main when off).
2026-07-15 09:50:05 -07:00

5 KiB

Profile-Based Routing for Inbound Messages

Audience: Gateway operators and contributors Source files: gateway/profile_routing.py, gateway/run.py (_profile_name_for_source), gateway/platforms/base.py (build_source), gateway/config.py Related: Session Lifecycle, docs/design/profile-builder.md

Overview

By default a single gateway run uses one profile (memory, persona, tools). Profile-based routing lets one gateway instance serve multiple isolated profiles, selecting which profile handles an inbound message based on where the message came from — the platform, server (guild_id), channel (chat_id), and/or thread (thread_id).

This is the inbound counterpart to multiplexing: instead of running N gateways, run one gateway and route per-community / per-channel / per-thread to a dedicated profile. Each profile keeps fully isolated state (MEMORY.md, USER.md, SOUL.md, sessions, tools).

Routing is platform-generic: it works for Discord, Telegram, Feishu, Slack, and every adapter — not just Discord.

Configuring routes

Routes live under profile_routes in config.yaml. Both the top-level and the nested gateway.profile_routes forms are accepted (the nested form is what hermes config set gateway.profile_routes ... writes).

profile_routes:
  # Route an entire Discord server (guild) to one profile.
  - name: server-default
    platform: discord
    guild_id: "1234567890"
    profile: server-profile

  # Override a specific channel within that server with a different profile.
  - name: support-channel
    platform: discord
    guild_id: "1234567890"
    chat_id: "9876543210"
    profile: support-profile

  # Pin a Telegram group to a profile (Telegram has no guild_id — chat_id only).
  - name: tg-group
    platform: telegram
    chat_id: "-1001234567890"
    profile: tg-profile

  # Route a single Discord thread.
  - name: standup-thread
    platform: discord
    guild_id: "1234567890"
    chat_id: "9876543210"
    thread_id: "1111111111"
    profile: standup

Fields

Field Required Description
name yes Human-readable route identifier (used in logs).
platform yes Adapter platform: discord, telegram, feishu, slack, …
profile yes Target profile name (must exist under ~/.hermes/profiles/<name>).
guild_id no Server/guild (Discord).
chat_id no Channel/group/DM id.
thread_id no Thread id within a channel.
enabled no Default true; set false to disable a route without removing it.

Matching rules

A route matches an inbound source when every discriminator the route declares is satisfied (conjunctive / AND). A field the route leaves unset is ignored.

  • platform must equal the source platform exactly.
  • thread_id (if set) must equal the source thread id.
  • chat_id (if set) must match the source channel or its parent — a thread in a channel matches the channel's route (hierarchical match for Discord forums/threads).
  • guild_id (if set) must equal the source guild.

A route declaring both guild_id and chat_id requires both to hold. A channel match alone does not satisfy a guild constraint — this is intentional and tested.

When multiple routes match, the most specific one wins. Specificity is additive:

Discriminator Weight
thread_id 8
chat_id 4
guild_id 2
(platform only) 0

So a thread route (8) beats a channel route (4) beats a guild route (2) within the same server. If no route matches, the message uses the default/active profile.

How it works at runtime

  1. An inbound message arrives at a platform adapter.
  2. BasePlatformAdapter.build_source builds the SessionSource for the message. Every adapter carries a back-reference to the running GatewayRunner (gateway_runner, injected in gateway/run.py), so it asks the runner to resolve the target profile via _profile_name_for_source.
  3. _profile_name_for_source runs the configured routes through match_profile_route and stamps source.profile with the winning route's profile (or leaves it unset).
  4. Downstream, _resolve_profile_home_for_source chooses the profile home directory (source.profile → active profile → default) and the session is scoped per-profile, so each routed community gets isolated memory and conversation state.

Because gateway_runner is injected for all adapters (declared on BasePlatformAdapter), every platform goes through this path — not just Discord.

Relationship to multiplexing

profile_routes requires gateway.multiplex_profiles: true. Multiplexing is what activates the per-profile runtime scope (per-profile HERMES_HOME, secret scope, and profile-namespaced session keys); routing is the decision layer that picks which profile a given guild/channel/thread lands in. With multiplexing off, profile_routes is ignored entirely — behavior is byte-identical to a single-profile gateway.