Skip to content

[Feature]: Per-channel profile routing for Discord (single bot, single gateway) #19809

@0xLT

Description

@0xLT

Problem or Use Case

Currently, running multiple distinct Hermes personalities on Discord requires N bot applications, N bot tokens, N gateway processes, and N systemd services.

The gateway is the I/O layer (Discord connection, auth, session lookup, media). The profile is the state layer (sessions, memory, skills, SOUL). Running N gateways to get N profiles duplicates the I/O layer to achieve isolation that lives one layer underneath it.

Proposed Solution

One Discord bot. Different channels backed by different Hermes profiles.

Single Discord bot
  ├── #coder      → profile: coder
  ├── #research   → profile: research
  └── #ops        → profile: ops

Each channel gets that profile's own sessions, memory, skills, SOUL.md, model config, and terminal.cwd — exactly what hermes profile create already gives you, just selected by channel instead of by gateway process.

Proposed config

discord:
  profile_routing:
    enabled: true
    default_profile: default        # DMs and unmapped channels
    channels:
      "1234567890": coder
      "2345678901": research
      "3456789012": ops
  • enabled: true — opt-in. When false or absent, behavior is identical to today.
  • default_profile: default — which profile handles DMs and unmapped channels. If omitted, the gateway's own profile handles them (current behavior).
  • channels — map of Discord channel ID (string) → profile name. Profile must exist under ~/.hermes/profiles/<name>/.
  • Threads inherit their parent channel's profile. No per-thread config needed.

How it should work

When a Discord message arrives in a mapped channel:

  1. The gateway resolves the channel ID to a profile name from discord.profile_routing.channels.
  2. If mapped, it loads that profile's config.yaml (for model, provider, toolset resolution) and resolves paths under ~/.hermes/profiles/<name>/.
  3. The session key is namespaced: profile:<name>:<base_key>. This isolates the in-memory agent cache, model overrides, and session store lookups per profile automatically.
  4. An AIAgent is constructed (or reused from cache) with the profile's SOUL, memory directory, and skills directory — not the gateway's default.
  5. A per-profile SessionStore points to <profile_home>/sessions/ and a per-profile SessionDB to <profile_home>/state.db. These are cached on the GatewayRunner so they're not recreated every turn.
  6. If unmapped, the message falls through to the default agent — identical to today.

What gets isolated per profile

Subsystem Isolated? How
SOUL.md Yes Read from <profile_home>/SOUL.md
Memory Yes MemoryManager initialized with <profile_home>
Skills Yes Loaded from <profile_home>/skills/
Sessions Yes SessionStore at <profile_home>/sessions/
State DB Yes SessionDB at <profile_home>/state.db
Model / provider / toolsets Yes Read from <profile_home>/config.yaml
terminal.cwd Yes Set in profile config
Agent cache Yes Session key prefix profile:<name>:

What stays shared (by design)

Subsystem Shared? Why
MCP servers Shared Process-level singletons. Per-profile MCP is out of scope; users who need it can still run separate gateways.
Plugins Shared Same reasoning.
Credential pools Shared By design — key rotation spans all profiles.
Tools Shared Tools are stateless. They use cwd and env, which are already per-profile via config.
Logging Shared Profile name appears in session key in logs — sufficient for observability.

Implementation approach: targeted in-process overrides

Rather than replacing every get_hermes_home() call in the codebase (fragile — miss one and you silently leak state), add three explicit optional parameters to AIAgent.__init__:

def __init__(
    self,
    ...,
    soul_path: Optional[str] = None,      # override SOUL.md location
    memory_home: Optional[str] = None,     # override memory storage root
    skills_dir: Optional[str] = None,      # override skills loading directory
    ...,
):

When provided, the agent uses these paths instead of deriving from get_hermes_home(). When None (the default), behavior is unchanged. This means:

  • Graceful degradation — if a subsystem isn't wired up for the override yet, it falls back to the global home rather than silently mixing profile data.
  • No get_hermes_home() replacement needed — logging, MCP init, plugin loading, and other process-level concerns continue using the gateway's home. They don't need per-profile isolation.
  • Small blast radius — changes are additive. Three new optional params, a routing lookup in the message handler, and per-profile session store caching.

Gateway dispatch changes

In GatewayRunner._handle_message_with_agent, after building the session source but before creating the session entry:

  1. Check discord.profile_routing.channels for the channel ID.
  2. If mapped, resolve the profile home, load its config, and swap in the profile's session store and state DB.
  3. Prefix the session key with profile:<name>:.
  4. Pass soul_path, memory_home, and skills_dir overrides through _run_agent to the AIAgent constructor.

In _run_agent, add the profile params to the signature and forward them to AIAgent:

async def _run_agent(
    self,
    ...,
    profile_name: Optional[str] = None,
    profile_home: Optional[str] = None,
):

The existing AIAgent() construction site (~line 12569 in gateway/run.py) passes the overrides:

soul_path=str(Path(profile_home) / "SOUL.md") if profile_home else None,
memory_home=str(profile_home) if profile_home else None,
skills_dir=str(Path(profile_home) / "skills") if profile_home else None,

Subsystem plumbing

Three small changes to accept the overrides:

agent/prompt_builder.pyload_soul_md():

def load_soul_md(soul_path: Optional[Path] = None) -> str:
    path = soul_path or (get_hermes_home() / "SOUL.md")

agent/skill_utils.pyget_all_skills_dirs():

def get_all_skills_dirs(skills_dir: Optional[Path] = None) -> list:
    base = skills_dir or get_hermes_home()

agent/memory_manager.py — already accepts hermes_home kwarg in initialize_all(). No change needed if the caller passes memory_home as hermes_home.

Config validation

On gateway startup, if discord.profile_routing.enabled is true:

  • Validate each profile name in channels has a directory at ~/.hermes/profiles/<name>/.
  • Warn (not error) for missing profiles, and fall back to default_profile for those channels.
  • Validate default_profile exists if specified.

Profile config resolution

When a profile is mapped, its config.yaml is read and used for:

  • Model selection (model.default, model.provider)
  • Toolset enablement (platform_toolsets)
  • Agent settings (agent.max_turns, etc.)
  • terminal.cwd

The profile's .env is NOT loaded into os.environ globally (race condition with concurrent turns). Instead, API keys and provider settings from the profile's config are passed explicitly to the agent construction. If the profile needs a provider not configured in the gateway's .env, the profile's .env values should be read and merged into the runtime kwargs without mutating the global environment.

Thread behavior

  • Threads inherit their parent channel's profile. The session key for a thread is profile:<name>:<base_thread_key> — same profile prefix as the parent channel.
  • No new config knob for per-thread profile selection.

CLI commands

No new CLI subcommands needed for the core feature. Profile management uses the existing hermes profile commands.

Optional convenience (post-MVP):

# Map a channel to a profile (updates config.yaml)
hermes gateway route add --channel 1234567890 --profile coder

# List current routing
hermes gateway route list

# Remove a mapping
hermes gateway route remove --channel 1234567890

Slash commands

No new slash commands needed. The routing is config-driven and transparent to users in Discord.

Optional post-MVP: /profile in a channel shows which profile is active for that channel.

What happens on error

Scenario Behavior
Mapped profile directory missing Log warning, fall back to default_profile (or gateway default)
Profile config.yaml unreadable Same fallback
Profile's model/provider misconfigured Agent construction fails gracefully; error message sent to channel
Two channels mapped to same profile Works fine — they share that profile's memory/sessions via different session keys
Profile routing enabled but no channels mapped No-op; all messages use default

Not in scope

  • Per-profile MCP server isolation (would require process separation)
  • Cross-guild profile routing (one bot in multiple Discord servers)
  • Per-user profile selection within a channel
  • Auto-provisioning profiles or Discord channels

Alternatives Considered

  1. Multiple bot apps, one per profile (today's workaround). Works, but every persona means another Developer Portal app, token, invite, and gateway process. Doesn't scale, and the server gets cluttered with multiple bot identities.
  2. One profile + discord.channel_prompts per channel. Supported today, but prompts are ephemeral — channels still share one profile's memory, sessions, skills, and terminal.cwd. No real isolation.
  3. Hot-swap HERMES_HOME per request inside one gateway. Same UX as the proposal, but tears down and re-inits agent state every message. Routing keeps each profile's runtime warm and dispatches to it, which is closer to how get_hermes_home() already resolves paths.

Feature Type

Gateway / messaging improvement

Scope

None

Contribution

  • I'd like to implement this myself and submit a PR

Debug Report (optional)

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Low — cosmetic, nice to havecomp/gatewayGateway runner, session dispatch, deliveryplatform/discordDiscord bot adaptertype/featureNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions