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:
- The gateway resolves the channel ID to a profile name from
discord.profile_routing.channels.
- If mapped, it loads that profile's
config.yaml (for model, provider, toolset resolution) and resolves paths under ~/.hermes/profiles/<name>/.
- 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.
- An
AIAgent is constructed (or reused from cache) with the profile's SOUL, memory directory, and skills directory — not the gateway's default.
- 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.
- 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:
- Check
discord.profile_routing.channels for the channel ID.
- If mapped, resolve the profile home, load its config, and swap in the profile's session store and state DB.
- Prefix the session key with
profile:<name>:.
- 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.py — load_soul_md():
def load_soul_md(soul_path: Optional[Path] = None) -> str:
path = soul_path or (get_hermes_home() / "SOUL.md")
agent/skill_utils.py — get_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
- 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.
- 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.
- 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
Debug Report (optional)
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.
Each channel gets that profile's own sessions, memory, skills, SOUL.md, model config, and
terminal.cwd— exactly whathermes profile createalready gives you, just selected by channel instead of by gateway process.Proposed config
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>/.How it should work
When a Discord message arrives in a mapped channel:
discord.profile_routing.channels.config.yaml(for model, provider, toolset resolution) and resolves paths under~/.hermes/profiles/<name>/.profile:<name>:<base_key>. This isolates the in-memory agent cache, model overrides, and session store lookups per profile automatically.AIAgentis constructed (or reused from cache) with the profile's SOUL, memory directory, and skills directory — not the gateway's default.SessionStorepoints to<profile_home>/sessions/and a per-profileSessionDBto<profile_home>/state.db. These are cached on theGatewayRunnerso they're not recreated every turn.What gets isolated per profile
<profile_home>/SOUL.mdMemoryManagerinitialized with<profile_home><profile_home>/skills/SessionStoreat<profile_home>/sessions/SessionDBat<profile_home>/state.db<profile_home>/config.yamlprofile:<name>:What stays shared (by design)
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 toAIAgent.__init__:When provided, the agent uses these paths instead of deriving from
get_hermes_home(). WhenNone(the default), behavior is unchanged. This means: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.Gateway dispatch changes
In
GatewayRunner._handle_message_with_agent, after building the session source but before creating the session entry:discord.profile_routing.channelsfor the channel ID.profile:<name>:.soul_path,memory_home, andskills_diroverrides through_run_agentto theAIAgentconstructor.In
_run_agent, add the profile params to the signature and forward them toAIAgent:The existing
AIAgent()construction site (~line 12569 ingateway/run.py) passes the overrides:Subsystem plumbing
Three small changes to accept the overrides:
agent/prompt_builder.py—load_soul_md():agent/skill_utils.py—get_all_skills_dirs():agent/memory_manager.py— already acceptshermes_homekwarg ininitialize_all(). No change needed if the caller passesmemory_homeashermes_home.Config validation
On gateway startup, if
discord.profile_routing.enabledis true:channelshas a directory at~/.hermes/profiles/<name>/.default_profilefor those channels.default_profileexists if specified.Profile config resolution
When a profile is mapped, its
config.yamlis read and used for:model.default,model.provider)platform_toolsets)agent.max_turns, etc.)terminal.cwdThe profile's
.envis NOT loaded intoos.environglobally (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.envvalues should be read and merged into the runtime kwargs without mutating the global environment.Thread behavior
profile:<name>:<base_thread_key>— same profile prefix as the parent channel.CLI commands
No new CLI subcommands needed for the core feature. Profile management uses the existing
hermes profilecommands.Optional convenience (post-MVP):
Slash commands
No new slash commands needed. The routing is config-driven and transparent to users in Discord.
Optional post-MVP:
/profilein a channel shows which profile is active for that channel.What happens on error
default_profile(or gateway default)Not in scope
Alternatives Considered
discord.channel_promptsper channel. Supported today, but prompts are ephemeral — channels still share one profile's memory, sessions, skills, andterminal.cwd. No real isolation.HERMES_HOMEper 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 howget_hermes_home()already resolves paths.Feature Type
Gateway / messaging improvement
Scope
None
Contribution
Debug Report (optional)