Gateway Route Bindings

Route inbound messages to the right agent by sender, role, channel, or bot account — with priority.

python
from praisonaiagents import Agent

vip_agent = Agent(name="vip-agent", instructions="Handle VIP user requests with priority.")
support_agent = Agent(name="support", instructions="Handle general support queries.")
vip_agent.start("Route messages from user 123 to me, all others to support.")

Route bindings let one gateway send the right message to the right agent — by who sent it, what role they have, which channel it landed in, or which bot account received it.

The user messages a channel; route bindings map that channel to the correct agent or workflow.

Diagram

Route Bindings

thread match

peer match

channel match acme

guild match globex

fallback

📩 Inbound Message

🧠 Extract Facts
peer · role · channel · account
thread · guild · parent

🎯 Match Binding
most-specific first

🎯 Escalations Agent

👑 VIP Agent

🛟 Support Agent
profile: acme

🛟 Support Agent
profile: globex

🤖 Default Agent

Quick Start

  1. Send everyone to one agent (baseline)

    Start with a single default route — no bindings needed.

    yaml
    agents:
      general:
        instructions: "You are a helpful assistant"
        model: gpt-4o-mini
    
    channels:
      telegram:
        token: ${TELEGRAM_BOT_TOKEN}
        routes:
          default: general
  2. Send one VIP user to a dedicated agent

    Add a bindings: entry with peer: set to the user’s Telegram numeric id. The VIP agent handles that user; everyone else still goes to general.

    yaml
    agents:
      general:
        instructions: "You are a helpful assistant"
        model: gpt-4o-mini
      vip:
        instructions: "You are the VIP concierge."
        model: gpt-4o
    
    channels:
      telegram:
        token: ${TELEGRAM_BOT_TOKEN}
        routes:
          default: general
        bindings:
          - { peer: "12345678", agent: vip }
  3. Mix peer, role, channel, and chat-type

    Stack multiple bindings — the most specific rule wins automatically.

    yaml
    channels:
      telegram:
        token: ${TELEGRAM_BOT_TOKEN}
        routes:
          default: general
        bindings:
          - { peer: "12345678", agent: vip }
          - { role: support, agent: support }
          - { channel_id: "-100999", agent: ops }
          - { chat_type: dm, agent: assistant }

    A user with id 12345678 always gets vip. A support-role member who DMs the bot gets support (role beats chat type). The ops channel routes to the ops agent. All other DMs go to assistant. Everything else falls back to general.

  4. Route a whole Discord server to one tenant

    Bind one guild_id: and every current and future channel in that server routes to the same agent.

    yaml
    channels:
      discord:
        token: ${DISCORD_BOT_TOKEN}
        routes:
          default: general
        bindings:
          - { guild_id: "9001", agent: support, profile: acme }

    A single rule handles every channel in the guild — no need to list them all, and new channels are covered automatically.

  5. Send one support thread to a specialist

    Layer a thread_id: binding on top of a channel or guild binding — a thread rule beats them both.

    yaml
    channels:
      discord:
        token: ${DISCORD_BOT_TOKEN}
        routes:
          default: general
        bindings:
          - { guild_id: "9001", agent: support, profile: acme }
          - { channel_id: "333", agent: sales,  profile: acme }    # also covers threads under 333
          - { thread_id: "77",  agent: escalations, profile: acme } # one thread beats the channel rule

    A thread rule beats a channel or guild rule — the specialist agent picks up messages in that one thread, everything else falls through.

  6. Restrict tools by trust tier

    Add trust: to any binding to scope the toolset the model sees — strangers get a safe subset, your operator keeps full power.

    yaml
    gateway:
      routes:
        - { chat_type: dm, agent: assistant, trust: untrusted }       # safe subset for strangers
        - { peer: "operator-123", agent: assistant, trust: trusted }  # full toolset for the operator
        - { channel_id: "ops", agent: assistant, deny_tools: [shell, delete_file] }

    See Gateway Tool Policy for the full security reference.

  7. Isolate tenants with a per-route profile

    Add profile: to any binding to enter a per-route memory namespace — the same support agent serves two customers with completely separate conversation histories.

    yaml
    channels:
      telegram:
        token: ${TELEGRAM_BOT_TOKEN}
        routes:
          default: general
        bindings:
          - { channel_id: "-100acme",   agent: support, profile: acme   }
          - { channel_id: "-100globex", agent: support, profile: globex }

    The gateway reads RouteMatch.profile and keys the turn’s memory/session state off the name, so each tenant’s transcript is stored separately (secret-scope and home isolation are follow-ups, not yet live — see Gateway Tenant Profiles). An unmatched route carries profile=None and stays unscoped, so misrouted traffic never leaks into another tenant.


How It Works

Diagram
Agentresolve_route()GatewayTelegram/Discord/Slack BotUserAgentresolve_route()GatewayTelegram/Discord/Slack BotUserSends a messageInbound facts (peer, roles, channel_id, account, chat_type, thread_id, guild_id, parent_channel_id)bindings + facts + default_agentRouteMatch (agent + reason)Dispatch to chosen agentReply

The gateway resolves the target agent in four deterministic steps:

StepWhat the gateway does
1Extracts peer, roles, channel_id, account, chat_type, thread_id, guild_id, parent_channel_id from the inbound message
2Tries every bindings: rule, keeps only the ones that match
3Sorts surviving matches: higher priority wins, then higher specificity, then declaration order
4If no binding matches, falls back to routes[chat_type], then routes["default"], then the literal agent id "default"

Parent-chain matching

A channel_id condition now matches the channel itself and any thread/forum-post whose parent is that channel — one channel rule naturally covers the conversations beneath it.

Diagram

Parent-chain match

matches

📩 Message in
thread-abc

channel_id: thread-abc
parent_channel_id: 333

Binding:
channel_id: 333

✅ Sales Agent

If you want a specific thread to escape the parent’s route, add a thread_id: binding — it beats the channel/guild rule via specificity.

A legacy channel_id-only binding still resolves exactly as before — parent-chain only widens what it matches, never narrows.


Routes vs Bindings

Diagram

Yes

No

Yes

Not sure

Yes

No

Do you only need to route
by chat type — dm / group / channel?

Use flat routes: map only

Do you need to route by
user, role, channel, or bot account?

Use bindings:
keep routes: default as fallback

Do you multiplex whole servers
or route specific threads?

Use guild_id: for whole servers
thread_id: for specific threads
channel_id: covers threads below it

Use both — bindings: for specific cases,
routes: for fallback


Configuration Options

Each entry in the bindings: list is a RouteBinding:

FieldTypeDefaultDescription
agentstr_required_Agent id to route to when this binding matches
chat_typeOptional[str]None"dm" \"group" \"channel"
peerOptional[str]NoneSender/user id (most specific)
roleOptional[str]NoneRole / guild-role membership of the sender
channel_idOptional[str]NoneSpecific chat/channel id. Also matches messages in a thread/forum-post whose parent is this id — a channel-level route naturally covers the threads beneath it.
thread_idOptional[str]NoneMatch a specific thread / forum-post id — most specific unit. One triage-spun support thread can route to a specialist agent.
guild_idOptional[str]NoneMatch a whole server / workspace id (Discord guild, Slack workspace). One rule covers every current and future channel in the guild.
accountOptional[str]NoneReceiving bot account (multi-account channels)
priorityint0Higher wins; ties broken by specificity then declaration order
trustOptional[str]NoneTrust tier — "untrusted" \"standard" \"trusted". untrusted applies a conservative deny-list (no shell, no file mutation, no delegation, no self-scheduling). Unknown values fail closed to untrusted.
allow_toolsOptional[List[str]]NoneOnly these tool names are exposed on this route. Accepts a YAML scalar or list.
deny_toolsOptional[List[str]]NoneTool names removed before the run on this route. Layers on top of the trust tier’s deny-list. Accepts a YAML scalar or list.
profileOptional[str]NoneIsolated tenant-profile name. Keys the turn’s memory/session state per tenant (secret-scope / home are follow-ups, not yet live), so one gateway can safely multiplex tenants’ conversation histories. None means the route is unscoped — the gateway never falls back to another tenant’s profile. Blank/whitespace strings are normalised to None. See Gateway Tenant Profiles.

All non-None conditions in a binding must match the inbound message for that binding to apply. A binding with no conditions always matches.

Specificity weights — when two bindings both match, the one with the higher total specificity wins:

FieldSpecificity weight
thread_id32 (most specific)
peer16
role8
channel_id8
guild_id4
account4
chat_type2

Ties on (priority, specificity) are broken by declaration order — the first matching binding in your list wins.


Fail-Fast Validation

A typo in a routes: slot or a bindings.agent value is caught the moment the config is loaded — the gateway refuses to start with an error naming the channel, the bad target, and the closest valid agent id.

Diagram

Yes

No — ‘personl’

📄 gateway.yaml
agents: personal, support

🔎 Every route/binding
target in agents:?

✅ Gateway starts

❌ ValueError
did you mean ‘personal’?

A single mistyped target used to slip through as a WARNING and silently serve the wrong agent for the whole deployment. Now it stops the gateway before it starts.

yaml
agents:
  personal:
    instructions: "Handle DMs from me."
  support:
    instructions: "Handle team support."

channels:
  telegram:
    token: ${TELEGRAM_BOT_TOKEN}
    routes:
      dm: personl      # typo — 'personal' misspelled
      default: support

Starting this config fails immediately with the closest-agent hint:

bash
$ praisonai gateway start --config gateway.yaml
ERROR: Invalid gateway/bot configuration:
channel 'telegram' route 'dm' -> unknown agent 'personl'; did you mean 'personal'? valid agents: personal, support

When no agent id is close enough, the hint is dropped but the valid list stays:

channel 'telegram' route 'dm' -> unknown agent 'xyz'; valid agents: personal, support

A bad bindings.agent fails the same way:

channel 'telegram' binding -> unknown agent 'personl'; did you mean 'personal'? valid agents: personal, support

The check covers the default slot, any custom slot (dm, group, …), routing: overrides, and every bindings.agent. A blank target (dm: "") is rejected too — only omitted slots are skipped.


Common Patterns

python
from praisonaiagents import Agent
from praisonaiagents.gateway import RouteBinding, RouteFacts, resolve_route

vip = Agent(name="vip", instructions="You are the VIP concierge.")
general = Agent(name="general", instructions="You are the general assistant.")

bindings = [
    RouteBinding(agent="vip", peer="12345678"),
    RouteBinding(agent="general", chat_type="dm"),
]

facts = RouteFacts(chat_type="dm", peer="12345678")
match = resolve_route(bindings, facts, default_agent="general")

print(match.agent)   # "vip"
print(match.reason)  # "matched binding (priority=0, specificity=16)"

Multiplex tenants with a per-route profile

python
from praisonaiagents import Agent
from praisonaiagents.gateway import RouteBinding, RouteFacts, resolve_route

support = Agent(name="support", instructions="Help the customer.")

bindings = [
    RouteBinding(agent="support", channel_id="slack-acme",   profile="acme"),
    RouteBinding(agent="support", channel_id="slack-globex", profile="globex"),
]

match = resolve_route(bindings, RouteFacts(channel_id="slack-globex"))

print(match.agent)    # "support"
print(match.profile)  # "globex"  ← gateway keys memory off this (secrets: follow-up)

An unmatched route resolves to profile=None so the wrapper stays unscoped rather than falling back into another tenant’s namespace.

VIP customer gets a dedicated agent

yaml
channels:
  telegram:
    token: ${TELEGRAM_BOT_TOKEN}
    routes:
      default: general
    bindings:
      - { peer: "12345678", agent: vip }

Support-role members (Discord) get the support agent; everyone else gets general

yaml
channels:
  discord:
    token: ${DISCORD_BOT_TOKEN}
    routes:
      default: general
    bindings:
      - { role: support, agent: support }

Force an override regardless of specificity using priority

yaml
channels:
  telegram:
    token: ${TELEGRAM_BOT_TOKEN}
    routes:
      default: general
    bindings:
      - { peer: "12345678", agent: vip }
      - { chat_type: dm, agent: incident_responder, priority: 100 }

The incident_responder binding has priority: 100 so it wins over the peer match for user 12345678, even though peer has higher specificity. Use this for incident-mode overrides.

Lock down stranger DMs while keeping operator at full power

yaml
channels:
  telegram:
    token: ${TELEGRAM_BOT_TOKEN}
    routes:
      default: assistant
    bindings:
      - { chat_type: dm, agent: assistant, trust: untrusted }
      - { peer: "operator-123", agent: assistant, trust: trusted }

Strangers who DM the bot never see shell, file-mutation, delegation, or scheduling tools. Your operator (peer: "operator-123") retains the full toolset. See Gateway Tool Policy for the complete security reference.

Route a whole Discord server to one tenant profile

yaml
channels:
  discord-acme:
    platform: discord
    token: ${ACME_DISCORD_TOKEN}
    routes:
      default: general
    bindings:
      - { guild_id: "9001", agent: support, profile: acme }

Escalate one support thread to a specialist agent

yaml
channels:
  discord:
    token: ${DISCORD_BOT_TOKEN}
    routes:
      default: general
    bindings:
      - { channel_id: "333", agent: support }
      - { thread_id: "77",  agent: escalations }

A channel route automatically covers its threads

yaml
channels:
  discord:
    token: ${DISCORD_BOT_TOKEN}
    routes:
      default: general
    bindings:
      - { channel_id: "sales", agent: sales }   # matches messages in "sales" and every thread under it

Resolve a thread over a whole-server rule in Python

python
from praisonaiagents.gateway import RouteBinding, RouteFacts, resolve_route

bindings = [
    RouteBinding(agent="support", guild_id="9001", profile="acme"),
    RouteBinding(agent="escalations", thread_id="77", profile="acme"),
]
facts = RouteFacts(
    chat_type="channel", channel_id="333", thread_id="77",
    guild_id="9001", parent_channel_id="333",
)
match = resolve_route(bindings, facts)
print(match.agent)   # "escalations"  ← thread beats guild
print(match.profile) # "acme"

Best Practices

Always declare a default in routes:

Bindings are evaluated first, but routes.default is the safety net when nothing matches — always include it. In a multi-agent config (with an agents: map), a typo in the default target no longer falls through silently: it fails at load with a clear Fail-Fast Validation error naming the closest valid agent.

yaml
routes:
  default: general   # always include this — must name an agent in agents:
bindings:
  - { peer: "12345678", agent: vip }
Prefer specificity over priority

Let the resolver pick by specificity — peer beats role beats channel_id beats account beats chat_type. Reach for priority only when you genuinely need to override, such as an incident-mode binding that must win regardless of user identity.

Use stable peer and channel ids

Telegram numeric ids and Discord channel ids are stable. Display names and usernames change. Use the numeric id from the platform — never a username or handle.

Keep bindings short and reviewable

If your list grows past ~10 entries, consider grouping users by role at the platform level and binding on role instead of individual peer ids. A long list of peer bindings is hard to audit and easy to break.

Name the isolation scope on the binding, not the agent

The same agent can serve many customers if each route carries its own profile. The gateway reads RouteMatch.profile and keys the turn’s memory namespace off the name (secret-scope / home remain follow-ups). Leaving profile off means the route is unscoped; a fallback match never inherits another tenant’s profile, so misrouted traffic can’t leak conversation history across customers.

Use guild_id for whole-tenant routing

When each tenant owns a Discord server or Slack workspace, bind on guild_id: instead of listing every channel. A newly-created channel in that server automatically inherits the guild’s route, and pairing it with profile: gives each tenant its own memory namespace. See Gateway Tenant Profiles.

Escalate to a thread, not a new channel

For triage patterns — a general support agent spins up a thread for a hard case — bind the thread id to the specialist agent. The thread rule beats the parent channel’s rule automatically, and messages in every other thread still route through the parent.


Related