# 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](session-lifecycle.md), `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).

```yaml
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.
