Gateway Tenant Profiles

Isolate tenant memory per route — the same agent serves many tenants without sharing conversation history

A route’s profile: field keys memory and session state per tenant on every turn, so one gateway multiplexes many tenants and their conversation histories never cross.

python
from praisonaiagents import Agent

# Same agent, two tenants — memory never crosses between them.
support = Agent(name="support", instructions="Help the customer.")
support.launch(gateway_config="gateway.yaml")
gateway.yaml
agents:
  support:
    instructions: "Help the customer."
    model: gpt-4o-mini

channels:
  discord-acme:
    platform: discord
    token: ${ACME_DISCORD}
    routes:
      default: { agent: support, profile: acme }
  slack-globex:
    platform: slack
    token: ${GLOBEX_SLACK}
    routes:
      default: { agent: support, profile: globex }

Two channels, two tenants, one agent — acme’s chat history and globex’s chat history are stored under separate keys and never leak into each other.

Diagram

Per-Route Tenant Isolation

channel: discord-acme

channel: slack-globex

no match

📩 Inbound

🎯 resolve_route

🏢 profile: acme

🏢 profile: globex

🔒 profile: None

Quick Start

  1. Bind two tenants to the same agent

    Give each route a different profile: value. The same support agent serves both tenants, but each route names its own memory namespace.

    yaml
    agents:
      support:
        instructions: "You are the support assistant."
        model: gpt-4o-mini
    
    routing:
      bindings:
        - agent: support
          channel_id: discord-acme
          profile: acme
        - agent: support
          channel_id: slack-globex
          profile: globex
  2. Read the resolved profile in code

    resolve_route() copies the winning binding’s profile onto the RouteMatch, so the gateway knows which tenant namespace to enter for the turn.

    python
    from praisonaiagents.gateway import RouteBinding, RouteFacts, resolve_route
    
    bindings = [
        RouteBinding(agent="support", channel_id="discord-acme", profile="acme"),
        RouteBinding(agent="support", channel_id="slack-globex", profile="globex"),
    ]
    
    match = resolve_route(bindings, RouteFacts(channel_id="discord-acme"), default_agent="support")
    
    print(match.agent)    # "support"
    print(match.profile)  # "acme"
  3. Isolate a whole tenant server in one line

    Bind profile: on a guild_id rule to isolate a whole tenant server — every current and future channel in the guild inherits the namespace.

    yaml
    channels:
      discord-fleet:
        platform: discord
        token: ${DISCORD_BOT_TOKEN}
        routes:
          default: general
        bindings:
          - { guild_id: "9001", agent: support, profile: acme   }  # entire acme server → acme namespace
          - { guild_id: "9002", agent: support, profile: globex }  # entire globex server → globex namespace

    A channel-level route also covers the threads beneath it — see Parent-chain matching.

  4. Leave a route unscoped

    Omit profile: (or leave it blank) and the route stays unscoped — match.profile is None and its memory is never namespaced.

    python
    match = resolve_route(bindings, RouteFacts(channel_id="unknown"), default_agent="support")
    
    print(match.agent)    # "support"  (fallback)
    print(match.profile)  # None       (never inherits acme or globex)

How It Works

Diagram
Memory/HistoryBotSessionManagerGatewayGlobex userAcme userMemory/HistoryBotSessionManagerGatewayGlobex userAcme user“What’s my order status?”set_profile_namespace(“acme”)read/write key = profile:acme:<user>reply (from acme’s transcript)“What’s my order status?”set_profile_namespace(“globex”)read/write key = profile:globex:<user>reply (from globex’s transcript, never sees acme)

resolve_route() picks the most-specific matching binding and copies its profile onto the returned RouteMatch; the gateway then enters that namespace before the agent runs, and every storage key for the turn is prefixed with profile:<name>:. An unmatched route yields RouteMatch(profile=None) and stays unscoped.


What the Profile Isolates Today

Only memory/session state is namespaced by the profile today; the other dimensions named on the routing protocol are separable follow-ups.

Storage-key example

The tenant name appears as profile:<name>: at the head of the key, so you can reason about exactly what is isolated.

# Unscoped route (no profile)
storage_key = "u1"

# Route with profile: acme
storage_key = "profile:acme:u1"

# Route with profile: acme + per_chat scope
storage_key = "profile:acme:telegram:acct:default:chat:-100123:"

When To Use a Profile

Diagram

No

Yes

Yes

No — they can share

Are you serving more than one
customer/tenant from this gateway?

Leave profile: unset

Should their conversation histories
be isolated from each other?

Add profile: to each route
using a stable tenant id


Fail-Closed Contract


Configuration Options

FieldTypeDefaultDescription
profile (on RouteBinding)Optional[str]NoneIsolated tenant-profile name the route enters. Blank/whitespace coerces to None.
profile (on RouteMatch)Optional[str]NonePopulated by resolve_route() from the winning binding; None when unmatched or unscoped.

At runtime the resolved profile flows into BotSessionManager‘s storage-key derivation on every routed turn — Discord and Slack via _routed_message_handler, Telegram via handle_message — so the memory namespace is applied before the agent runs.


Best Practices

Give every tenant its own profile name

Use one stable, tenant-specific value per route (acme, globex). Never reuse a profile name across tenants — that would merge their conversation histories.

Treat None as fail-closed, not shared

When match.profile is None, do not fall back to a “default tenant” that another route also uses. Unscoped means isolated, not shared.

Bind tenants on stable identifiers

Route tenant profiles on channel_id or account — stable platform ids — rather than display names, which change.

Keep profile names out of user-controlled input

Profile names come from your config, not from message content. Never derive a profile from a field a user can set.

Verify isolation by checking a storage key

Confirm a tenant is isolated by inspecting the head of its storage key — the tenant name must appear as profile:<name>: before the base id (e.g. profile:acme:u1). An unscoped route shows no prefix (u1).


Related