Telegram Durable Approval

Durable, actor-authorised Allow/Deny buttons on Telegram that survive a bot restart

Set approval="presentation" on your Agent and run a Telegram bot — Allow/Deny buttons render on chat, resolve against a SQLite store, and survive a restart. It works the same in standalone (Bot("telegram", ...)) and gateway mode.

Diagram

Telegram Durable Approval

🤖 Agent

🔒 ApprovalBackend

💾 SQLite Store

👆 Allow / Deny

✅ Resolved

Quick Start

  1. Auto-wire from the agent

    Set approval="presentation" on the Agent — the bot wires the durable backend to the chat on start(). No register_approval_backend() call needed.

    python
    from praisonaiagents import Agent
    from praisonai.bots import Bot
    
    agent = Agent(
        name="Ops",
        instructions="Ask before destructive tools.",
        approval="presentation",   # durable + actor-authorised
        tools=["execute_command"],
    )
    
    bot = Bot("telegram", agent=agent)
    bot.run()

    The bot detects the backend on the agent and connects it to the chat automatically. Unknown decision values (anything outside allow, deny, always) are rejected before reaching the backend — fail closed.

  2. Manual wire (advanced)

    Wire a backend you constructed separately — for tests or custom bootstrap where the backend is built by hand.

    python
    from praisonaiagents import Agent
    from praisonai.bots import Bot, PresentationApprovalBackend, ApprovalStore
    
    backend = PresentationApprovalBackend(
        store=ApprovalStore(path="~/.praisonai/state/approvals.sqlite"),
        allowed_actors={"12345"},
    )
    
    agent = Agent(name="Ops", instructions="Ask before destructive tools.")
    bot = Bot("telegram", agent=agent)
    bot.register_approval_backend(backend, target="chat-1")
    bot.run()

    register_approval_backend gives the backend the channel renderer and target, then enables the /approve callback route.


How It Works

An agent tool call persists to the store, renders Allow/Deny buttons, and unblocks only when an authorised tap resolves the approval ID.

Diagram
SQLiteUserTelegramApprovalBackendAgentSQLiteUserTelegramApprovalBackendAgentrisky tool callpersist(approval_id)render Allow / Deny🔒 approval cardtap Allow_handle_approve_callbackresolve(approval_id)unblock (approved)

Each decision binds to an unguessable approval_id, not a message id — so a late tap after a redeploy still resolves against the SQLite store instead of falling through as an unhandled callback.


Gateway mode

The same approval="presentation" setting works when Telegram runs under the gateway — no extra wiring. The gateway’s Telegram channel handles inline-button taps itself, so pairing Approve/Deny and tool-approval taps resolve against the SQLite store without a second getUpdates poller on the token.

python
from praisonaiagents import Agent

agent = Agent(
    name="Ops",
    instructions="Ask before destructive tools.",
    approval="presentation",
    tools=["execute_command"],
)
# Run this agent under the gateway with your usual gateway.yaml.

Gateway Telegram also forwards chat and message IDs with each turn, so duplicate-message dedup, session_scope: per_chat, and /new behave the same as standalone. A turn waiting on an approval no longer blocks the tap that resolves it.


What the user sees

The whole point of the feature — a non-developer only ever sees two buttons and a result.

Diagram

tap Allow

tap Deny

nobody taps in time

unauthorised tap / superseded turn

🔒 Tool-approval card arrives

Two inline buttons

✅ Approved

❌ Denied

⌛ Timed out — not run

⚠️ Could not be processed

The original message updates in place to one of four outcomes:

  • ✅ Approved — an authorised reviewer tapped Allow and the tool ran.
  • ❌ Denied — an authorised reviewer tapped Deny and the tool did not run.
  • ⌛ Timed out — not run — nobody tapped before the deadline. The agent gets a fail-closed DENY, so the tool did not run.
  • ⚠️ Approval could not be processed — the tap was unauthorised, or the originating turn was /stopped or superseded while the card was still open.

Superseded turns

A reviewer can tap Allow minutes or hours after the card first appears. If the user cancelled the run (or a new turn took over) in the meantime, that late tap must not fire the tool. The core SDK enforces exactly this since PraisonAI #4950, so it applies to every backend on this page — not just the durable Telegram one:

  • The tool does not run.
  • No session/always grant is persisted for the abandoned turn.
  • The returned decision carries reason="Turn no longer live (stopped or superseded)".

See Approval Turn Liveness and Approval Protocol → Live-Authority Binding.


Configuration Options

Backend construction lives on the secure-backend page — this page only wires it to Telegram.


Common Patterns

Auto-wire from agent (default). Set approval="presentation" on the Agent and run the bot — nothing else. This is the Quick Start path.

python
agent = Agent(name="Ops", instructions="Ask first.", approval="presentation")
bot = Bot("telegram", agent=agent)
bot.run()

Manual wire with explicit target. Address approval buttons to a specific chat.

python
bot.register_approval_backend(backend, target="chat-1")

Restart drill. Restart the bot; a late Allow tap on a message sent before the restart still resolves. rehydrate_approvals() runs automatically on start() and returns the restored count.

python
restored = await bot.rehydrate_approvals()
print(f"Restored {restored} pending approvals")

Best Practices

Set PRAISONAI_APPROVAL_ACTORS

The backend is fail-closed — set the actor allowlist so only your approvers can resolve a tap. Without it, no actor is authorised.

One transport per backend

Do not pass an already-wired backend from Slack to Telegram. When a backend already owns a sender, the coupling guard leaves it untouched so it never renders on one transport while addressing another.

In the gateway, shell-routed agent clones reuse the channel’s wired backend only when the target chat matches and the approver set is the same or narrower. Otherwise the clone keeps its own backend.

Prefer the auto-wire path

Reserve register_approval_backend() for tests or custom bootstrap. In normal deployments, approval="presentation" on the Agent is enough.

Log the rehydrated count on startup

start() logs how many approvals were restored. Watch that number to catch a broken deploy where pending approvals never come back.


Related