Bot Pairing

Secure unknown user onboarding with CLI-approved pairing codes

Bot pairing lets unknown users self-request access to your bot with secure 8-character codes that you approve from the CLI.

python
from praisonaiagents import Agent, BotConfig

agent = Agent(name="assistant", instructions="You are a helpful assistant.")
config = BotConfig(unknown_user_policy="pair")
agent.start("An unknown user wants access โ€” generate a pairing code.")

The user DMs the bot for the first time; PraisonAI issues an 8-character code you approve from the CLI.

Diagram

Pairing Flow

๐Ÿ‘ค Unknown User

๐Ÿค– Bot DM

๐Ÿ” Generate Code

๐Ÿ“ฑ Send Code

๐Ÿ’ป CLI Approve

โœ… User Paired

Quick Start

  1. Enable Pairing Policy
    python
    from praisonaiagents import Agent
    from praisonaiagents import BotConfig
    
    config = BotConfig(
        allowed_users=["@owner"],
        unknown_user_policy="pair",  # Enable pairing for unknown users
    )
    
    # Bot setup with Telegram adapter (other platforms pending)
    # from praisonai.bots.telegram import TelegramAdapter
    # bot = TelegramAdapter(config)

    The same config from gateway.yaml โ€” both keys are read per-channel (PR #2856):

    yaml
    channels:
      telegram:
        token: ${TELEGRAM_BOT_TOKEN}
        unknown_user_policy: pair
        owner_user_id: ${TELEGRAM_OWNER_ID}
        routes:
          default: assistant

    Since PR #5111, praisonai bot start wires these fields too โ€” so you can drive the pairing flow from a single-bot bot.yaml without switching to praisonai gateway start:

    yaml
    # bot.yaml
    platform: telegram
    token: ${TELEGRAM_BOT_TOKEN}
    unknown_user_policy: pair
    owner_user_id: 987654321
    agent:
      name: Assistant
      instructions: Reply to incoming messages.
  2. Approve Pairing Requests
    bash
    # List pending pairing requests
    praisonai pairing list
    
    # Approve a user's pairing code
    praisonai pairing approve telegram ABCD1234 --label "alice"
    
    # View all paired channels
    praisonai pairing list

How It Works

Diagram
OwnerPairingStoreHandlerBotUserOwnerPairingStoreHandlerBotUserReceives notificationDM messageCheck if user allowedis_paired(channel_id)?Nogenerate_code(telegram, channel_id)ABCD1234Send pairing instructions“Code: ABCD1234”praisonai pairing approve telegram ABCD1234verify_and_pair() โ€” lockout check first, then consume codeMark channel as pairedNext DMCheck if user allowedis_paired(channel_id)?YesAllow messageNormal response

Policy Configuration

Unknown User Policies

python
from praisonaiagents import BotConfig

# Policy options
config = BotConfig(
    allowed_users=["@owner", "123456789"],
    unknown_user_policy="pair"  # Choose one below
)
PolicyBehaviorUse Case
"deny"Silently drop messages (default)Private bots, testing
"allow"Allow all users (overrides allowed_users)Public bots
"pair"Use pairing flow for approvalControlled access

Pairing Rate Limiting

The system includes built-in protection against code generation spam:

  • Rate Limit: 10 minutes between code generations per channel
  • Code TTL: Codes expire after a configurable time (default: check PaisingStore implementation)
  • Automatic Cleanup: Stale rate limit entries are automatically evicted

CLI Commands

List Commands

bash
# List all paired channels
praisonai pairing list

# Example output:
# Found 3 paired channels:
#
# Platform: telegram
# Channel:  @alice_username  
# Paired:   2026-04-22 10:30:15
# Label:    alice
#
# Platform: telegram
# Channel:  987654321
# Paired:   2026-04-22 11:45:22
# Label:    bob

Approve Command

bash
# Approve with auto-resolved channel ID (when code is bound)
praisonai pairing approve telegram ABCD1234

# Approve with explicit channel ID
praisonai pairing approve telegram ABCD1234 987654321

# Approve with human-readable label
praisonai pairing approve telegram ABCD1234 --label "alice"

# Use custom store directory
praisonai pairing approve telegram ABCD1234 --store-dir /path/to/store

Revoke Access

bash
# Revoke specific channel
praisonai pairing revoke telegram 987654321

# Clear all pairings (with confirmation)
praisonai pairing clear
# Are you sure you want to clear ALL paired channels? [y/N]: y
# โœ… Cleared 3 paired channels

# Clear without confirmation prompt
praisonai pairing clear --confirm

Web-UI / HTTP API Approval

Approve, list, and revoke pairings from a web admin UI via the gateway’s HTTP API.

Diagram
sequenceDiagram
    participant Browser as Browser (Chainlit/Admin UI)
    participant API as POST /api/pairing/approve
    participant Store as PairingStore
    participant Bus as EventBus
    participant Bot

    Browser->>API: Approve pairing request
    API->>Store: approve(channel, code)
    Store-->>API: Success
    API->>Bus: pairing_approved event
    Bus->>Bot: Event notification
    Bot-->>Browser: User acknowledged

    classDef user fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef api fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef event fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef success fill:#10B981,stroke:#7C90A0,color:#fff

    class Browser user
    class API,Store api
    class Bus event
    class Bot success

Endpoints

List Pending

GET /api/pairing/pending

Returns pending pairing requests awaiting approval.

bash
curl -X GET "http://localhost:8000/api/pairing/pending" \
  -H "Cookie: session=<admin-session-cookie>"
python
import httpx

resp = httpx.get(
    "http://localhost:8000/api/pairing/pending",
    cookies={"session": "<admin-session-cookie>"},
)
resp.raise_for_status()
print(resp.json())  # {"pending": [{"channel_type": "ui", "code": "ABCD1234", "channel_id": "user123", "age_seconds": 45}]}

Approve Pairing

POST /api/pairing/approve

Approve a pairing code to authorize the channel.

bash
curl -X POST "http://localhost:8000/api/pairing/approve" \
  -H "Content-Type: application/json" \
  -H "Cookie: session=<admin-session-cookie>" \
  -d '{"channel": "ui", "code": "ABCD1234"}'
python
import httpx

# Admin operator approves a pending UI pairing
resp = httpx.post(
    "http://localhost:8000/api/pairing/approve",
    json={"channel": "ui", "code": "ABCD1234"},
    cookies={"session": "<admin-session-cookie>"},
)
resp.raise_for_status()
print(resp.json())  # {"approved": True, "channel": "ui", "code": "ABCD1234"}

Revoke Access

POST /api/pairing/revoke

Revoke authorization for a previously paired channel.

bash
curl -X POST "http://localhost:8000/api/pairing/revoke" \
  -H "Content-Type: application/json" \
  -H "Cookie: session=<admin-session-cookie>" \
  -d '{"channel": "ui", "user_id": "user123"}'
python
import httpx

resp = httpx.post(
    "http://localhost:8000/api/pairing/revoke",
    json={"channel": "ui", "user_id": "user123"},
    cookies={"session": "<admin-session-cookie>"},
)
resp.raise_for_status()
print(resp.json())

Reacting to Pairing Approvals (EventBus)

Subscribe to the pairing_approved event to run agent logic the moment a channel is approved โ€” for example, send a welcome DM, log to your CRM, or warm a per-user memory store.

Diagram

Approve API

EventBus

Agent Handler

python
from praisonaiagents import Agent
from praisonaiagents.bus import get_default_bus

welcomer = Agent(
    name="Welcomer",
    instructions="Write a one-line friendly welcome message for a new user.",
)

def on_pairing_approved(event):
    channel = event.data["channel"]
    code = event.data["code"]
    welcome = welcomer.start(f"Welcome the new {channel} user with code {code}")
    print(welcome)

get_default_bus().subscribe(on_pairing_approved, ["pairing_approved"])

Event Payload

FieldTypeDescription
event.typestrAlways "pairing_approved"
event.data["channel"]strChannel type (e.g., "telegram", "ui", "slack")
event.data["code"]strThe pairing code that was approved

Platform Support

Current Implementation

PlatformStatusHandler WiringCLI Support
Telegramโœ… Shippedโœ… Completeโœ… Full
Web UI (HTTP API)โœ… Shippedโœ… via /api/pairing/* routesโœ… Full
Discord๐Ÿ”ง PendingโŒ Not wiredโœ… CLI ready
Slack๐Ÿ”ง PendingโŒ Not wiredโœ… CLI ready
WhatsApp๐Ÿ”ง PendingโŒ Not wiredโœ… CLI ready

Telegram Integration

python
# Telegram adapter includes UnknownUserHandler wiring
from praisonai.bots.telegram import TelegramAdapter
from praisonaiagents import BotConfig

config = BotConfig(
    token=os.getenv("TELEGRAM_BOT_TOKEN"),
    unknown_user_policy="pair"
)

# Handler automatically wired in Telegram adapter
bot = TelegramAdapter(config)

Security Model

Store Hardening (PraisonAI #3974)

PraisonAI #3974 hardens the pairing store without changing its public API โ€” legacy plaintext pairing.json stores still load, so there is nothing to migrate.

At-rest hashing

Since #3974, only a salted SHA-256 of each pending code is written to pairing.json. Reading the file never yields a usable code, and a rainbow table is useless without the per-install HMAC secret used as salt.

json
{
  "pending": [
    {
      "code_hash": "9f2cโ€ฆ64-hex-charsโ€ฆa1",
      "signature": "โ€ฆ",
      "channel_type": "telegram",
      "channel_id": "tg_42",
      "created_at": 1755255600.0
    }
  ]
}

On a fresh process, praisonai pairing list (or PairingStore.list_pending()) against a store whose code was created in another process returns entries with code: None and the code_hash โ€” the pending request stays visible, the raw code does not.

Store-level brute-force lockout

verify_and_pair() counts failed verifications per channel_type and refuses further attempts once the ceiling trips โ€” uniformly across every approval surface (HTTP route, in-chat Approve/Deny button, CLI praisonai pairing approve). Tune via constructor kwargs:

Constructor kwargDefaultMeaning
max_failures5Consecutive failed verifications before lockout trips
lockout_window60.0 sSliding window that counts failures
lockout_cooldown300.0 sHow long a channel_type is refused once tripped

A success clears the counter; a valid HMAC presented against the wrong channel_id does not (otherwise a repeated wrong-binding guess would reset the budget). Once locked out, even a valid code for that channel_type is refused until the cooldown expires โ€” the lockout is store-level, not surface-level.

Unambiguous alphabet

Generated codes use ABCDEFGHJKLMNPQRSTUVWXYZ23456789 โ€” 8 characters, dropping 0/O/1/I so codes read aloud or retyped from another device are not mis-entered. Length is unchanged; entropy rises to log2(32^8) = 40 bits.

Code Generation

  • 8-character codes: Unambiguous alphabet ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (drops 0/O/1/I, since #3974)
  • HMAC signatures: Codes are cryptographically signed
  • Hashed at rest: Only a salted SHA-256 of each code is persisted (since #3974)
  • Per-install secret: Auto-generated if PRAISONAI_GATEWAY_SECRET unset
  • Channel binding: Codes can be bound to specific channel IDs

Secret Management

bash
# Option 1: Set explicit gateway secret (recommended for production)
export PRAISONAI_GATEWAY_SECRET="your-secure-secret-key"

# Option 2: Auto-generated per-install secret
# Stored at ~/.praisonai/gateway/.gateway_secret with 0600 permissions
# Persists across restarts, unique per installation

An existing 0o666 file left over from an older install is fixed automatically on the next startup โ€” no manual chmod needed. See Security โ†’ PR #2850 for the hardening summary.

Diagram

PairingStore init

Yes

No ยท POSIX

No ยท Windows

OK

OSError

๐Ÿ“„ Read .gateway_secret

๐Ÿ” mode == 0o600?

โœ… Return secret

๐Ÿ”ง chmod 0o600

๐Ÿ”ง icacls best-effort

โ›” raise โ€” fail closed

Security Features

Diagram

Yes

No

Yes

No

Code Request

Rate Limited?

Drop Request โŒ

Generate HMAC Code

Store with TTL

Send to User

Owner Approval

Valid Code?

Mark Paired โœ…

Reject โŒ

  1. Rate Limiting: 600s (10 min) window per channel prevents spam
  2. HMAC Verification: Codes are cryptographically signed and verified
  3. TTL Expiration: Codes automatically expire after configured time
  4. Atomic Operations: Pairing state persisted atomically to disk

User Interaction Flow

Step-by-Step Process

  1. Unknown User DMs Bot
Unknown User: Hello!
  1. Bot Generates Pairing Code
Bot: Your pairing code: `ABCD1234`
Owner: `praisonai pairing approve telegram ABCD1234`
  1. Owner Approves via CLI
bash
$ praisonai pairing approve telegram ABCD1234 --label "alice"
โœ… Successfully paired telegram channel 987654321
   Label: alice
  1. User Can Now Interact Normally
Unknown User: Hello!
Bot: Hi there! How can I help you today?

Rate Limit Handling

If a user tries to generate codes too frequently:

User: Hello!
Bot: [no response - rate limited]

# In logs:
# DEBUG: Rate limited channel 987654321 (last code: 120.5s ago)

The user must wait for the rate limit window (10 minutes) to expire before requesting a new code.


Configuration Options

Store Directory

bash
# Default: ~/.praisonai/pairing/
praisonai pairing list

# Custom directory
praisonai pairing list --store-dir /custom/path

# All commands support --store-dir
praisonai pairing approve telegram ABCD1234 --store-dir /custom/path

Environment Variables

bash
# Explicit gateway secret (recommended for production)
export PRAISONAI_GATEWAY_SECRET="your-256-bit-secret"

# Custom store directory (optional)
export PRAISONAI_STORE_DIR="/custom/store/path"

Best Practices

Production Secret Management

Set PRAISONAI_GATEWAY_SECRET explicitly in production environments to ensure consistent code verification across deployments.

bash
# Generate secure secret
openssl rand -hex 32 > gateway_secret.txt

# Set in production
export PRAISONAI_GATEWAY_SECRET=$(cat gateway_secret.txt)
Monitor Rate Limits

Watch for rate limiting warnings in logs – they indicate potential pairing spam or legitimate users hitting limits.

bash
# Look for these log patterns:
# DEBUG: Rate limited channel 123456 (last code: 45.2s ago)
# INFO: Generated pairing code for user123 on telegram: ABCD1234
Use Descriptive Labels

Add labels when approving pairings to identify channels later.

bash
# Good - easy to identify
praisonai pairing approve telegram ABCD1234 --label "alice-work"
praisonai pairing approve telegram EFGH5678 --label "bob-personal"

# Less helpful
praisonai pairing approve telegram ABCD1234
Regular Pairing Audits

Periodically review paired channels and revoke access for inactive users.

bash
# Review all pairings
praisonai pairing list

# Revoke specific channels  
praisonai pairing revoke telegram 987654321

# Clear all if starting fresh
praisonai pairing clear --confirm

Related