Aleph
Architecture

Routing System

Aleph's real routing layer: session-key resolution (SessionKey), DmScope isolation, hierarchical RouteBinding matching, two-stage command/keyword routing rules, runtime overlay, provider failover, and VESR.

This page describes Aleph's real routing layer (src/routing/ + src/config/types/routing.rs), not a generic web routing framework.

The Aleph routing system has four layers:

  1. Slash-command parsing (src/command/parser.rs) — resolves /cmd input to a ParsedCommand via ToolCatalog::resolve_command's three-tier match
  2. Session-key resolution (src/routing/session_key.rs) — parses every inbound message into a hierarchical SessionKey that decides agent / conversation / scope
  3. Route-binding resolution (src/routing/resolve.rs + config.rs) — maps channel / peer / guild / team / account / default to a ResolvedRoute via conjunctive scope matching
  4. Runtime overlay (src/routing/overlay.rs) — applies agent_switch bindings + ghost-binding detection so the gateway_route tool reports the truth

The two-stage command/keyword AI routing rules live in src/config/types/routing.rs; the [[rules]] middleware delivery lives in src/config/methods.rs.


1. Slash-command parsing (src/command/parser.rs)

CommandParser is the single entry point for every /cmd input. It delegates to ToolCatalog::resolve_command (src/tool_metadata/registry/query.rs):

Input "/session new my-topic"


trim + must start with '/'


strip "@botname" (Telegram group commands: /gen@mybot)


split on whitespace → ["session", "new", "my-topic"]


ToolCatalog::resolve_command(input):
    ① greedy longest-match: try session_new_my_topic → session_new → session
    ② find_best_match three tiers: canonical name → alias → `.`↔`_` fallback
    ③ max_depth = 3


ParsedCommand { tool_id, command_name, source_type, context, arguments }

tool_id is the verbatim UnifiedTool::id (e.g. builtin:session_new, mcp:fs:read_file, plugin:diagnostics:ping, custom:3:translate). Downstream consumers — the command.execute RPC, the channel fast-path serializer — no longer reconstruct it lossily from source_type + command_name, which is the fix that stopped plugin slash commands from being mangled into mcp__plugin:<id>_<name> and silently failing.

CommandContext derives from tool.source:

ToolSourceCommandContextNotes
Builtin / NativeBuiltin { tool_name }Direct-tool fast path
Mcp { server }Mcp { server_name, tool_name }MCP server namespace
Skill { id }Skill { skill_id, instructions, display_name, allowed_tools }Inject skill instructions + allowed-tools whitelist
Plugin { .. }Builtin { tool_name: tool.id }Plugin runs through direct-tool fast path; canonical id plugin:<plugin_id>:<name> survives intact
Custom { .. }Custom { system_prompt, provider, pattern }Custom rules carry system_prompt + regex template

2. SessionKey (src/routing/session_key.rs)

#[serde(tag = "type", rename_all = "snake_case")]
pub enum SessionKey {
    Main { agent_id: String, main_key: String, epoch: u32 },
    DirectMessage { agent_id: String, channel: String, peer_id: String, dm_scope: DmScope, epoch: u32 },
    Group { agent_id: String, channel: String, peer_kind: PeerKind, peer_id: String, thread_id: Option<String> },
    Task { agent_id: String, task_type: String, task_id: String },
    Subagent { parent_key: Box<Self>, subagent_id: String },
    Ephemeral { agent_id: String, ephemeral_id: String },
}

#[derive(Default)]
pub enum DmScope { Main, #[default] PerPeer, PerChannelPeer }

pub enum PeerKind { Group, Channel, Thread }

Constructors and key strings

ConstructorPurposeExample key string
SessionKey::main("main")Cross-channel shared main sessionagent:main:main
SessionKey::dm(agent, channel, peer, DmScope::PerPeer)Per-user DMagent:main:telegram:dm:user123
SessionKey::group(agent, channel, PeerKind::Group, id)Groupagent:main:discord:group:guild-id
SessionKey::task(agent, "cron", "daily-summary")cron / heartbeat / a2a / webhookagent:main:cron:daily-summary
SessionKey::subagent(parent, id)Sub-agent nestedagent:main:main:subagent:translator
SessionKey::ephemeral(agent)Ephemeral (no persistence)agent:main:ephemeral:<uuid>

Key methods

  • format_dm_base — folds DM into agent:{id}:main when DmScope::Main
  • base_key_pattern — strips the :sN epoch suffix for SQL LIKE queries; for non-epoch variants (Group / Task / Subagent / Ephemeral) it equals to_key_string
  • with_epoch / with_next_epoch — epoch roll (only Main / DirectMessage carry epoch)
  • is_interactivetrue only for Main + DirectMessage + Group; false for Task / Subagent / Ephemeral. This is the naked-loop planner gate: a cron job, group-chat member, or sub-agent's first turn must never trip the planner (R7 — an origin fact, not a message-content heuristic). Fail-closed for any future internal variant
  • main_session_key — return to the parent's main session
  • task_type guardSessionKey::task(..) rejects peer / dm / ephemeral at construction so the serialized key cannot parse as a DM or Ephemeral

Cross-channel identity links: canonical_name → [channel:id, ...]. validate_identity_links rejects the same channel:id under two canonicals at config-load time (otherwise a reload can flip the tie-break and leak messages across users):

[session.identity_links]
john = ["telegram:123", "discord:456"]

3. Route-binding resolution (resolve.rs + config.rs)

resolve_route(bindings, session_cfg, default_agent, input) -> ResolvedRoute is a deterministic pure function — intent classification is the LLM's job (R7); no regex layer lives here.

RouteBinding configuration

pub struct RouteBinding {
    pub agent_id: String,
    pub match_rule: MatchRule,   // Serialized as [match] in TOML
}

pub struct MatchRule {
    pub channel: Option<String>,     // "telegram" / "discord" / "slack" / "*"
    pub account_id: Option<String>,  // Specific bot account; absent → matches default account; "*" → wildcard
    pub peer: Option<PeerMatchConfig>,
    pub guild_id: Option<String>,    // Discord guild
    pub team_id: Option<String>,     // Slack workspace
    pub workspace: Option<String>,   // Force this workspace on match (overrides the user's active workspace)
}

Match order (MatchedBy)

Bindings are evaluated in this priority; first match wins:

PriorityMatchedByTrigger
1Peermatch_rule.peer matches input.peer (kind + id)
2Guildmatch_rule.guild_id matches input.guild_id
3Teammatch_rule.team_id matches input.team_id
4Accountaccount_id is non-wildcard (and no peer / guild / team scope)
5Channelaccount_id is wildcard or absent (and no narrower scope)
6DefaultNothing matched → fall through to default_agent

Scopes are conjunctive. scope_satisfied filters candidates by the full conjunction of channel / account / peer / guild / team: a { team_id, peer } rule means "this peer in that team", not "any peer" or "any peer in that team". This closes the cross-workspace conversation-id collision that previously routed Slack channels from the wrong workspace.

agent_id is reported as configured. session_keys_for normalizes internally for the key, but the binding's agent_id is returned verbatim — [[agents.list]] id = "Work_Bot" is also stored verbatim in the registry, so normalizing it during routing pointed at an agent that didn't exist and triggered a misleading "agent deleted" gate.

Configuration examples

# All traffic on the default Telegram account goes to telegram-agent
[[routing.bindings]]
agent_id = "telegram-agent"
[bindings.match]
channel = "telegram"
account_id = "*"

# Only within a specific Slack workspace, route to work
[[routing.bindings]]
agent_id = "work"
[bindings.match]
channel = "slack"
account_id = "*"
team_id  = "T12345"

# VIP user (on any channel) → vip-agent
[[routing.bindings]]
agent_id = "vip-agent"
[bindings.match]
peer = { kind = "dm", id = "user-vip" }

# Force matched Telegram sessions onto the "crypto" workspace
[[routing.bindings]]
agent_id = "main"
[bindings.match]
channel    = "telegram"
account_id = "*"
workspace  = "crypto"

Startup-time error visibility

binding_problems scans [[bindings]] at boot and translates bindings that can never match (empty agent_id, peer.kind not in dm|group|channel, empty peer.id, or a rule with no scope at all that would shadow every binding after it) into startup log lines — reporting, not rejecting, so a bad binding cannot brick the daemon and the rest of the table still routes.


4. Runtime overlay (overlay.rs)

resolve_route answers from config — the [[bindings]] table snapshotted at boot. Two runtime facts sit on top:

  1. An explicit per-channel binding set at runtime by agent_switch or the Panel, which must beat a merely-default config answer (otherwise the namesake action is a silent no-op on any deployment that has bindings)
  2. Whether the agent a specific binding names still exists — agents.delete cannot rewrite routing config, so a binding can outlive its target, and routing to the ghost would brick the channel forever

Both rules used to live only inside the inbound router, so gateway_route — whose entire job is telling the model where a message would go — answered from config alone and confidently reported the wrong agent. overlay::overlay_route is the shared decision: each caller supplies the runtime facts it can see, and the composition is written once.

pub struct RuntimeOverlay<'a> {
    pub channel_override: Option<&'a str>,  // agent_switch binding whose target still exists
    pub bound_agent_exists: bool,           // does the binding's named agent still exist?
}

pub enum OverlaySource {
    Binding(MatchedBy),             // config wins
    ChannelOverride,                // explicit override wins
    BindingAgentMissing,            // named agent gone → fall through to default
}

pub struct OverlaidRoute {
    pub agent_id: Option<String>,   // None → caller fills in default agent
    pub source: OverlaySource,
}

5. RoutingRuleConfig (src/config/types/routing.rs)

pub struct RoutingRuleConfig {
    pub rule_type: Option<String>,         // "command" | "keyword" | auto-detected by regex
    pub is_builtin: bool,                  // true: read-only in Settings UI
    pub regex: String,
    pub provider: Option<String>,          // required for command rules
    pub system_prompt: Option<String>,     // required for keyword rules; optional for command
    pub strip_prefix: Option<bool>,        // default true for command; ignored for keyword
    pub intent_type: Option<String>,       // "translation" / "research" / "code_generation" / "skills:build-macos-apps"
    pub preferred_model: Option<String>,   // overrides automatic model selection
    pub icon: Option<String>,              // SF Symbol icon (command-mode display)
}

get_rule_type() auto-detects from the ^/ prefix; should_strip_prefix() returns false for keyword rules and the configured value (defaulting to true) for command rules.

Configuration examples

[[rules]]
rule_type = "command"
regex = "^/draw\\s+"
provider = "gemini"
system_prompt = "Draw a picture based on the prompt"
intent_type = "image_generation"
preferred_model = "gemini-2.5-pro-image"
icon = "paintbrush.fill"

[[rules]]
rule_type = "keyword"
regex = "translate to English"
system_prompt = "Translate the target language to English"
intent_type = "translation"

# Bypass: the regex already strips (e.g. /^/echo\s+(.+)/ → only \1 reaches the AI)
[[rules]]
rule_type = "command"
regex = "^/echo\\s+(.+)"
provider = "default"
strip_prefix = false   # AI side does not strip again

6. Two-stage matching (command + keyword)

The matching pipeline runs two ordered phases per input:

process_clipboard / inbound message

    ├─ Phase 1: command rules (first-match-stops)
    │     ├─ regex starts with ^/ → exactly one match → stop
    │     └─ on hit → use its provider + optional strip_prefix

    └─ Phase 2: keyword rules (all-match)
          ├─ regex does not start with / → accumulate all matches
          └─ on hits → concatenate system_prompts with \n\n, use default_provider

strip_prefix is applied to slash commands (e.g. /loop, /goal, /mode) so the AI never sees the command name as literal text. The command.executor dispatches via RegistryToolRegistry::resolve; the fast path lives in gateway::execution_engine::slash_command::try_resolve_slash_command (L0 direct) and falls back to execute_slash_command_fast_path.


7. Context-aware inference ([smart_matching])

SmartMatchingConfig (src/config/types/smart_flow.rs) defines a four-layer semantic matcher:

LayerNameThreshold fieldPurpose
1Fast path (command/regex)command_confidence = 1.0First-match-stops
2Keyword matchingkeyword_threshold = 0.7Weighted scoring
3Context-aware inferenceenable_context_inference = trueMulti-turn, app context, time context
4AI detection fallbackai_threshold = 0.6AI-first

Layer 3 (context-aware) is configured via context_rules: Vec<ContextRuleConfig>:

[smart_matching]
enabled = true
enable_context_inference = true

[[smart_matching.context_rules]]
id = "complete-pending-language"
condition_type = "pending_param"     # pending_param | app_context | time_context | conversation
param_name = "target_lang"
intent = "translation"
action_type = "complete_param"       # complete_param | add_capability | set_provider | add_prompt
use_input_as_value = true

[[smart_matching.context_rules]]
id = "weekend-tone"
condition_type = "time_context"
days_of_week = [0, 6]                # 0=Sun, 6=Sat
action_type = "add_prompt"
system_prompt = "Use a casual tone on weekends."

Layer 2 uses weighted keywords via [[smart_matching.keyword_rules]] ("keyword" or "keyword:1.5"), match modes any | all | weighted.


8. Provider failover and VESR

After routing picks a provider, the actual "which model can do the work" judgment lives in routing/recall.rs and VESR:

  • ProviderAvailability (recall.rs) — predicate Fn(&str) -> ProviderStatus compiled once at boot from config + vault
  • ProviderStatus::Available | Deconfigured | Unknown — render-time gate; unknown ids (e.g. "", "failover", "(dynamic)") fail OPEN, never penalizing what we cannot identify
  • RoutingRecall::build_routing_experience_message — called exactly once per run at run-start: embed the user query, backfill RoutingAttribution::task_emb, recall k-NN neighbors, mark unavailable-provider entries (O4: mark, not filter), fence-wrap via wrap_memory_context
  • RoutingExperienceStore (experience_store.rs) — sqlite-vec k-NN over recorded sessions to recall measured-good models for the task
  • RoutingAttribution (mod.rs) — per-run handle correlating run-start recall (writes task_emb) with the completion observer (reads it). One per run; lives in the gateway run loop, outside the harness
  • OutcomeObserver (observer.rs) — TraceSink::SessionCompleted → write to the routing experience table (tokens / cost / model / provider / agent_id)

See AI Providers for the full model-recall surface.


9. Composite keys (Loop / Goal / Strategy / Team)

SessionKey is the root of several composite-key namespaces (the task_type discriminates the Task variant; agent_id shares the same normalize rules everywhere):

task_type / namespaceSourcePurpose
cronsrc/tasks/cron/cron trigger
heartbeatsrc/tasks/heartbeat/daemon probe trigger
a2asrc/a2a/sub-agent delegation
webhookwebhookwebhook trigger
team_chatsrc/teams/broadcast/team chat

See Loop / Goal / Strategy and Cron Jobs.


10. RPC surface

MethodPurpose
routing_rules.listList all routing rules (in [[rules]] order)
routing_rules.getFetch a single rule
routing_rules.upsertCreate/update (guarded by is_builtin)
routing_rules.moveReorder (affects command-rule match priority)
routing_rules.deleteDelete (non-builtin only)
route_config.get / updateWhole [route_config] block
channels.set_agentBind a channel to a specific agent (26.7.15+ validates that the agent exists)
agent.resolve_sessionResolve any inbound metadata into a SessionKey
gateway_routeTool: report which agent the message would reach, with OverlaySource

Full RPC listings: Gateway methods · config-routing and Gateway methods · session.


11. Module layout

src/routing/
├── mod.rs                # entry; re-exports SessionKey / DmScope / resolve_route / overlay
├── session_key.rs        # SessionKey / DmScope / PeerKind / parse / to_key_string
├── resolve.rs            # resolve_route + scope_satisfied + session_keys_for
├── config.rs             # RouteBinding / MatchRule / SessionConfig / binding_problems
├── identity_links.rs     # resolve_linked_peer_id + validate_identity_links
├── overlay.rs            # overlay_route + RuntimeOverlay + OverlaySource
├── recall.rs             # RoutingRecall + ProviderAvailability + provider_availability_from_config
├── experience_store.rs   # VESR store (k-NN over sqlite-vec)
└── observer.rs           # OutcomeObserver + outcome_from_session_completed

src/command/
├── mod.rs                # CommandParser entry
└── parser.rs             # parse_async → ParsedCommand + tool_to_command_context

src/tool_metadata/registry/
├── mod.rs                # ToolCatalog — unified tool registry
└── query.rs              # resolve_command + find_best_match + suggest_commands

See also

On this page