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)

python
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

python
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.

Diagram

Cross-Platform Mirror

📱 Telegram: 12345

🪪 IdentityResolver

💬 Discord: snowflake-1

💼 Slack: U-A

👤 alice

💾 One Session History

How It Works

Diagram
💾 Unified History🤖 BotOS💬 Discord📱 Telegram👤 Alice💾 Unified History🤖 BotOS💬 Discord📱 Telegram👤 AliceHours later, different platform“My favourite colour is octarine.”chat(user_id=12345)append (key=alice)“Got it!”“What’s my favourite colour?”chat(user_id=snowflake-1)load (key=alice — same!)“Octarine.”

Quick Start

  1. 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()
  2. 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()
  3. 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

Diagram

tests / single proc

production + paired users

production, no pairing

multi-process / multi-host

What do you need?

InMemoryIdentityResolver

StoreBackedIdentityResolver
⭐ recommended

FileIdentityResolver

Custom IdentityResolverProtocol
SQLite / Redis / DB


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.

Diagram

Yes

No

Yes

No

📨 Incoming message
platform + user_id

Explicit link?

👤 canonical id

Paired with
non-empty label?

🪪 platform:user_id

Resolution order:

  1. Explicit link registered via link() (highest priority)
  2. Pairing-store label for (user_id, platform) when the channel is paired and carries a non-empty label
  3. f"{platform}:{user_id}" — safe per-platform fallback (no merging)
python
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

python
# 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)

yaml
# 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: false keeps today’s default — per-platform session keys.
  • enabled accepts true/false/"1"/"true"/"yes"/"on" (strings coerced case-insensitively).
  • store is ~-expanded; path is 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)

bash
praisonai gateway start --config gateway.yaml \
  --identity-store ~/.praisonai/identity.json
  • The CLI value overrides the YAML identity: block.
  • The value is ~-expanded.
  • If StoreBackedIdentityResolver can’t be imported (optional deps missing), a warning is logged and the gateway continues without the resolver.

Python override (always wins)

python
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.

Diagram

WebSocketGateway arg

–identity-store

identity: block

none

Resolver source?

🐍 Python override
always wins

💻 CLI flag

📄 YAML block

🪪 per-platform keys
bot_platform_userid

✅ Stamped onto every channel bot

The gateway stamps the resolver onto each channel bot’s session manager at startup and on every hot-reload:

Diagram
💬 Discord bot📱 Telegram bot🪪 IdentityResolver🗼 Gateway💬 Discord bot📱 Telegram bot🪪 IdentityResolver🗼 GatewayHot-reload restarts a channel → re-stamped in _start_single_channelbuild from YAML / CLI / constructorstart_channels → _stamp_identity_resolver(bot)bot._session._identity_resolver = resolverstart_channels → _stamp_identity_resolver(bot)bot._session._identity_resolver = resolver

End-to-end: one session across two platforms

  1. Operator approves both channels for the same label:
bash
praisonai pairing approve telegram <code> --label alice
praisonai pairing approve discord  <code> --label alice
  1. Operator adds identity: { enabled: true } to gateway.yaml (or passes --identity-store).
  2. Alice on Telegram: "my favourite colour is octarine".
  3. Hours later, Alice on Discord: "what's my favourite colour?".
  4. The gateway resolves both to alice, loads the same session history, and replies "Octarine.".
Diagram
💾 Unified History🗼 Gateway💬 Discord📱 Telegram👤 Alice💾 Unified History🗼 Gateway💬 Discord📱 Telegram👤 AliceHours later, different platform“My favourite colour is octarine.”chat(user_id=12345)append (key=alice)“Got it!”“What’s my favourite colour?”chat(user_id=snowflake-1)load (key=alice — same!)“Octarine.”

CLI: praisonai identity

Four subcommands manage identity links from the command line.

CommandPurpose
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 importMaterialise paired channels as explicit identity links

Each subcommand accepts:

OptionDefaultDescription
--path~/.praisonai/identity.json (or $PRAISONAI_IDENTITY_PATH)Override the link-map JSON path
--store-dirgateway defaultOverride the pairing store directory
bash
# 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 store

SessionContext for Tools

Any tool the agent calls can read who is messaging:

python
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

FieldTypeDescription
platformstr"telegram", "discord", "slack", …
chat_idstrPlatform chat / channel id
chat_namestrHuman-readable channel name
thread_idstrThread / topic id (Slack threads, Telegram topics)
user_idstrRaw platform user id
user_namestrDisplay name
unified_user_idstrResult of IdentityResolver.resolve()
originOptional[Origin]NonePlatform-aware origin info — see Platform-Aware Agents
reachable_targetsOptional[List[ReachableTarget]]NoneChannels 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.

Diagram
💾 One Session🤖 BotOS💬 Discord📱 Telegram👤 Alice💾 One Session🤖 BotOS💬 Discord📱 Telegram👤 Alicepar[Two platforms, same instant]shared turn lock on resolved id“What’s my favourite colour?”“And my favourite food?”chat(user=alice)chat(user=alice)Turn 1 append + reply (Telegram)“Octarine.”Turn 2 append + reply (Discord)“Pizza.”
WhenWhat happens
One adapter, one userTurns already serialise per user — unchanged.
Multiple adapters, no resolverEach adapter keeps its own per-user history (bot_{platform}_{user_id}) — no unified transcript, no shared lock needed. Unchanged.
Multiple adapters, with resolverTurns 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 → user order.

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)

python
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)

python
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}
Diagram

Agent reply

Command stdout,
backend output,
system notice

Who wrote the
mirrored text?

authored_by=’agent’
→ assistant turn

authored_by=’external’
→ [source_label] user turn

Parameters

ParameterTypeDefaultDescription
session_mgrBotSessionManager—Session manager instance
user_idstr—Unified user ID
message_textstr—Message content to mirror. Empty text returns False
source_labelstr"delivery"Source identifier ("cron", "web", "cross_platform"). Prefixed as [source_label] on external turns
metadatadictNoneOptional extra metadata. Reserved keys (role, content, timestamp, mirror, mirror_source) are ignored
lockthreading.RLockNoneOptional lock for synchronization
authored_bystr"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 two user turns 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.

python
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.

python
from praisonaiagents.session import FileIdentityResolver

resolver = FileIdentityResolver()  # ~/.praisonai/identity.json
Implement custom IdentityResolverProtocol for scale

For multi-process/multi-host deployments, back it with SQLite, Redis, or a database:

python
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
        pass
Pair before linking

Never auto-link based on display name. Always require a DM-verified pairing flow:

python
# 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:

python
def get_user_platform() -> str:
    ctx = get_session_context()
    return ctx.platform  # Thread-safe, context-aware

Related