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.
Quick Start
- Auto-wire from the agent
Set
approval="presentation"on the Agent — the bot wires the durable backend to the chat onstart(). Noregister_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. - 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_backendgives the backend the channel renderer and target, then enables the/approvecallback 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.
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.
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.
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/alwaysgrant 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.
PresentationApprovalBackend constructor args (store, allowed_actors, timeout)
The SQLite ApprovalStore schema behind the durable path
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.
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.
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.
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
The SQLite store behind the durable path
Secure Approval BackendThe PresentationApprovalBackend this page wires
The gateway sibling of this transport wiring
Messaging BotsDeploy agents to chat channels
Approval Turn LivenessA tap on a /stopped or superseded turn is dropped fail-closed
Why a late tap on a stopped turn is dropped fail-closed