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.
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")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.
Quick Start
- Bind two tenants to the same agent
Give each route a different
profile:value. The samesupportagent 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 - Read the resolved profile in code
resolve_route()copies the winning binding’sprofileonto theRouteMatch, 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" - Isolate a whole tenant server in one line
Bind
profile:on aguild_idrule 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 namespaceA channel-level route also covers the threads beneath it — see Parent-chain matching.
- Leave a route unscoped
Omit
profile:(or leave it blank) and the route stays unscoped —match.profileisNoneand 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
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.
Shipped (PR #4343). Every storage key for the turn is prefixed with profile:<name>:, so two routes multiplexed on one process never share a transcript.
Still follow-up. These dimensions are not yet consumed from the profile. Do not rely on the profile to scope secrets, override a model, or swap instructions.
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
Fail-Closed Contract
Configuration Options
| Field | Type | Default | Description |
|---|---|---|---|
profile (on RouteBinding) | Optional[str] | None | Isolated tenant-profile name the route enters. Blank/whitespace coerces to None. |
profile (on RouteMatch) | Optional[str] | None | Populated 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).