Cross-Platform Sessions
One conversation across Telegram, Discord, Slack — unified per-user history with opt-in identity linking
Cross-platform sessions let one user keep a single conversation across every messaging platform.
Recommended (with pairing)
from praisonai.bots import BotOS, StoreBackedIdentityResolver
from praisonaiagents import Agent
agent = Agent(name="assistant", instructions="Be helpful.")
# Reuses ~/.praisonai/identity.json + the gateway pairing store.
resolver = StoreBackedIdentityResolver.from_env()
BotOS(
agent=agent,
platforms=["telegram", "whatsapp", "discord", "slack"],
identity_resolver=resolver,
).run()Explicit links only
from praisonai.bots import BotOS
from praisonaiagents import Agent
from praisonaiagents.session import FileIdentityResolver
agent = Agent(name="assistant", instructions="Be helpful.")
resolver = FileIdentityResolver() # ~/.praisonai/identity.json
resolver.link("telegram", "12345", "alice")
resolver.link("discord", "snowflake-1", "alice")
BotOS(
agent=agent,
platforms=["telegram", "discord"],
identity_resolver=resolver,
).run()The user continues the same conversation on Telegram, then Discord; identity linking mirrors session history across platforms.
How It Works
Quick Start
- One platform, one user
python from praisonai.bots import Bot from praisonaiagents import Agent agent = Agent(name="assistant", instructions="Be helpful.") bot = Bot("telegram", agent=agent) await bot.start() - Two platforms, one user (recommended)
python from praisonai.bots import BotOS, StoreBackedIdentityResolver from praisonaiagents import Agent agent = Agent(name="assistant", instructions="Be helpful.") resolver = StoreBackedIdentityResolver.from_env() botos = BotOS( agent=agent, platforms=["telegram", "discord"], identity_resolver=resolver, ) await botos.start() - In-process testing / ephemeral
python from praisonai.bots import BotOS from praisonaiagents import Agent from praisonaiagents.session import InMemoryIdentityResolver agent = Agent(name="assistant", instructions="Be helpful.") resolver = InMemoryIdentityResolver() # Ephemeral for tests resolver.link("telegram", "12345", "alice") resolver.link("discord", "snowflake-1", "alice") botos = BotOS( agent=agent, platforms=["telegram", "discord"], identity_resolver=resolver, )
Choosing Your Resolver
StoreBackedIdentityResolver
StoreBackedIdentityResolver extends the core FileIdentityResolver with a read-through to the gateway pairing store, so users who have already paired share a session out of the box.
Resolution order:
- Explicit link registered via
link()(highest priority) - Pairing-store label for
(user_id, platform)when the channel is paired and carries a non-emptylabel f"{platform}:{user_id}"— safe per-platform fallback (no merging)
from praisonai.bots import StoreBackedIdentityResolver
# Defaults: ~/.praisonai/identity.json + gateway pairing store
resolver = StoreBackedIdentityResolver.from_env()
# Override paths
resolver = StoreBackedIdentityResolver.from_env(
path="/etc/praisonai/identity.json",
store_dir="/var/lib/praisonai/gateway",
)
# Direct construction with an existing pairing store
from praisonai.gateway.pairing import PairingStore
resolver = StoreBackedIdentityResolver(
path="~/.praisonai/identity.json",
pairing_store=PairingStore(),
use_pairing_label=True, # default
)link_paired() — Promote Paired Users to Explicit Links
# After channels have been paired with non-empty labels via the pairing CLI:
count = resolver.link_paired()
print(f"Materialised {count} paired channels into explicit links.")Useful when the pairing store is volatile or about to be rotated — this pins the canonical-id mapping into the resolver’s own JSON file.
In the gateway (praisonai gateway start)
The flagship WebSocketGateway daemon accepts the same resolver, so a paired user keeps one continuous session across every channel served by one gateway process.
Wire it three ways — YAML block, CLI flag, or Python override.
YAML (recommended for daemons)
# gateway.yaml
identity:
enabled: true # default when block is present
store: ~/.praisonai/identity.json # optional link-map path (also accepts `path:`)
agents:
assistant:
instructions: "Be helpful."
channels:
telegram: { token: "${TELEGRAM_BOT_TOKEN}" }
discord: { token: "${DISCORD_BOT_TOKEN}" }- A missing block, a non-mapping block, or
enabled: falsekeeps today’s default — per-platform session keys. enabledacceptstrue/false/"1"/"true"/"yes"/"on"(strings coerced case-insensitively).storeis~-expanded;pathis accepted as an alias.- Any failure to build the resolver degrades gracefully to per-platform keys and logs a warning — startup never aborts.
CLI (–identity-store)
praisonai gateway start --config gateway.yaml \
--identity-store ~/.praisonai/identity.json- The CLI value overrides the YAML
identity:block. - The value is
~-expanded. - If
StoreBackedIdentityResolvercan’t be imported (optional deps missing), a warning is logged and the gateway continues without the resolver.
Python override (always wins)
from praisonai_bot.gateway.server import WebSocketGateway
from praisonai_bot.bots import StoreBackedIdentityResolver
resolver = StoreBackedIdentityResolver.from_env()
gateway = WebSocketGateway(
host="127.0.0.1",
port=8765,
identity_resolver=resolver, # #3020 — stamped onto every channel bot
)
await gateway.start_with_config("gateway.yaml")A constructor resolver is marked explicit and is never rebuilt from YAML, even on hot-reload.
Precedence
WebSocketGateway(identity_resolver=…) > --identity-store > identity: YAML block > default (per-platform keys)
An explicit constructor or CLI resolver is never clobbered by the YAML block, even on hot-reload.
The gateway stamps the resolver onto each channel bot’s session manager at startup and on every hot-reload:
End-to-end: one session across two platforms
- Operator approves both channels for the same label:
praisonai pairing approve telegram <code> --label alice
praisonai pairing approve discord <code> --label alice- Operator adds
identity: { enabled: true }togateway.yaml(or passes--identity-store). - Alice on Telegram:
"my favourite colour is octarine". - Hours later, Alice on Discord:
"what's my favourite colour?". - The gateway resolves both to
alice, loads the same session history, and replies"Octarine.".
CLI: praisonai identity
Four subcommands manage identity links from the command line.
| Command | Purpose |
|---|---|
praisonai identity link <platform> <user_id> <canonical> | Map a (platform, user_id) to a canonical identity |
praisonai identity unlink <platform> <user_id> | Remove a mapping |
praisonai identity list [canonical] | Show all links, or links for a single canonical id |
praisonai identity import | Materialise paired channels as explicit identity links |
Each subcommand accepts:
| Option | Default | Description |
|---|---|---|
--path | ~/.praisonai/identity.json (or $PRAISONAI_IDENTITY_PATH) | Override the link-map JSON path |
--store-dir | gateway default | Override the pairing store directory |
# Link Alice's Telegram + WhatsApp identities to one canonical id
praisonai identity link telegram 12345 alice
praisonai identity link whatsapp "+44123456789" alice
# Inspect
praisonai identity list alice
# Links for alice:
# telegram:12345
# whatsapp:+44123456789
# Or list every mapping
praisonai identity list
# Unlink one channel
praisonai identity unlink telegram 12345
# Promote paired channels (labelled via `praisonai pairing approve ... --label`) into explicit links
praisonai identity import
# ✅ Imported 3 identity link(s) from pairing storeSessionContext for Tools
Any tool the agent calls can read who is messaging:
from praisonaiagents import Agent
from praisonaiagents.session import get_session_context
def whoami() -> str:
ctx = get_session_context()
return f"You are {ctx.user_name or ctx.user_id} on {ctx.platform}"
agent = Agent(name="assistant", instructions="Use whoami when asked.", tools=[whoami])SessionContext Fields
| Field | Type | Description | |
|---|---|---|---|
platform | str | "telegram", "discord", "slack", … | |
chat_id | str | Platform chat / channel id | |
chat_name | str | Human-readable channel name | |
thread_id | str | Thread / topic id (Slack threads, Telegram topics) | |
user_id | str | Raw platform user id | |
user_name | str | Display name | |
unified_user_id | str | Result of IdentityResolver.resolve() | |
origin | Optional[Origin] | None | Platform-aware origin info — see Platform-Aware Agents |
reachable_targets | Optional[List[ReachableTarget]] | None | Channels the agent can deliver to — see Platform-Aware Agents |
Concurrent Turns Across Adapters
With an identity resolver configured, near-simultaneous turns from different adapters that resolve to the same unified user run one at a time against that user’s single transcript.
Each adapter — Telegram, Discord, Slack — keeps its own session manager, and the identity resolver unifies them onto one persisted session per human. Before this fix, each adapter still guarded its turns separately, so two adapters could run turns for the same human at the same time and scramble the order of their shared transcript.
| When | What happens |
|---|---|
| One adapter, one user | Turns already serialise per user — unchanged. |
| Multiple adapters, no resolver | Each adapter keeps its own per-user history (bot_{platform}_{user_id}) — no unified transcript, no shared lock needed. Unchanged. |
| Multiple adapters, with resolver | Turns from any adapter for the same unified user run serially against the one shared transcript. This is the new behaviour. |
When it matters
- A cron or scheduled outbound delivery lands on platform A while the user replies on platform B — the mirror and the inbound reply serialise cleanly (see Mirror for Outbound Deliveries).
- A user types on Telegram while their smartwatch fires a Slack message in the same second — the second turn waits for the first to commit before appending, so the transcript stays in strict
user → assistant → userorder.
Mirror for Outbound Deliveries
Cron jobs, scheduled deliveries, and cross-platform replies need to mirror outbound text into the user’s history. Tell it who wrote the text with authored_by:
Agent-authored (default)
from praisonai.bots import mirror_to_session
# After the agent sends a notification
mirror_to_session(
session_mgr=bot._session,
user_id="alice",
message_text="Reminder: your standup is in 5 minutes.",
source_label="cron",
)
# → {"role": "assistant", "content": "Reminder: ...", "mirror": True}External (command output, notices)
from praisonai.bots import mirror_to_session
# Text the agent did not write — tag it "external"
mirror_to_session(
session_mgr=bot._session,
user_id="alice",
message_text="Backup finished: 3 files",
source_label="cron",
authored_by="external",
)
# → {"role": "user", "content": "[cron] Backup finished: 3 files", "mirror": True}Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
session_mgr | BotSessionManager | — | Session manager instance |
user_id | str | — | Unified user ID |
message_text | str | — | Message content to mirror. Empty text returns False |
source_label | str | "delivery" | Source identifier ("cron", "web", "cross_platform"). Prefixed as [source_label] on external turns |
metadata | dict | None | Optional extra metadata. Reserved keys (role, content, timestamp, mirror, mirror_source) are ignored |
lock | threading.RLock | None | Optional lock for synchronization |
authored_by | str | "agent" | "agent" mirrors an assistant turn. Any other value (e.g. "external") mirrors a user turn prefixed with [source_label] |
Role & alternation safety
BotSessionManager.chat() cleans the loaded history before every turn — no configuration needed:
coalesce_mirror_turns(history)merges a mirror entry into an adjacent same-role text turn (joined with a blank line). Ordinary history is untouched.fold_trailing_user_mirror(history, prompt)folds a trailing external mirror into the incoming prompt, so the agent never sends twouserturns in a row.
Both live in praisonai_bot.bots._mirror if you build a custom session manager.
Storage & Privacy
Best Practices
Use StoreBackedIdentityResolver for production (with pairing)
For production wrapper deployments, use StoreBackedIdentityResolver.from_env() — it picks up paired channels automatically and falls back to the same safe platform:user_id default. Plain FileIdentityResolver is the right choice only when you do not use the pairing system.
from praisonai.bots import StoreBackedIdentityResolver
resolver = StoreBackedIdentityResolver.from_env()Use FileIdentityResolver for explicit-only links
For production single-host bots without pairing integration, FileIdentityResolver provides persistent storage with atomic writes and proper file permissions.
from praisonaiagents.session import FileIdentityResolver
resolver = FileIdentityResolver() # ~/.praisonai/identity.jsonImplement custom IdentityResolverProtocol for scale
For multi-process/multi-host deployments, back it with SQLite, Redis, or a database:
from praisonaiagents.session import IdentityResolverProtocol
class DatabaseIdentityResolver:
def resolve(self, platform: str, platform_user_id: str) -> str:
# Query your database
pass
def link(self, platform: str, platform_user_id: str, unified_user_id: str) -> None:
# Store in your database
passPair before linking
Never auto-link based on display name. Always require a DM-verified pairing flow:
# After DM verification succeeds
resolver.link("telegram", telegram_user_id, verified_unified_id)
resolver.link("discord", discord_user_id, verified_unified_id)Read SessionContext in tools
Instead of os.environ, read SessionContext — concurrent message handlers won’t trample each other:
def get_user_platform() -> str:
ctx = get_session_context()
return ctx.platform # Thread-safe, context-awareRelated
Multi-platform bot orchestrator
Messaging BotsPlatform-specific bot guides
Bot PairingSecure unknown-user onboarding
Unknown-User PairingInline-button approval for bots
Gateway CLI--identity-store and other gateway start flags
Hot-reload the identity: block without dropping turns