Durable Approvals
Persist pending HITL approvals across bot restarts with SQLite-backed storage
Pending tool approvals normally live in process memory — a gateway restart loses in-flight decisions. A durable approval store persists them so a late Allow/Deny tap still resolves correctly.
from praisonai.bots import ApprovalStore, Bot
from praisonaiagents import Agent
agent = Agent(name="assistant", instructions="Be helpful")
bot = Bot(
"telegram",
agent=agent,
approval_store=ApprovalStore(path="~/.praisonai/state/approvals.sqlite"),
)
bot.run()The user taps Allow or Deny on a channel; the durable store resolves the pending approval even after a gateway restart.
Quick Start
- Create a store
python from praisonai.bots import ApprovalStore store = ApprovalStore( path="~/.praisonai/state/approvals.sqlite", ttl_seconds=7 * 86400, # evict resolved rows after 7 days ) - Pass it to your bot
python from praisonai.bots import Bot from praisonaiagents import Agent agent = Agent(name="assistant", instructions="Be helpful") bot = Bot("telegram", agent=agent, approval_store=store) bot.run() - Restart safely
On startup, outstanding pending approvals are re-hydrated. Users can tap Allow/Deny on messages sent before the restart.
How It Works
Each ApprovalRequest carries a stable approval_id (UUID hex) used as the correlation key across restarts.
Durable Chat-Native Backends
Every standalone chat backend — SlackApproval, TelegramApproval, DiscordApproval, WebhookApproval, and HTTPApproval — now accepts an optional store= for one-line restart safety.
Pass a store to any backend and the pending approval is persisted before polling; recover in-flight approvals on startup with rehydrate().
from praisonaiagents import Agent
from praisonai.bots import SlackApproval, ApprovalStore
store = ApprovalStore(path="~/.praisonai/state/approvals.sqlite")
agent = Agent(
name="ops",
instructions="Ask before destructive commands.",
approval=SlackApproval(channel="#approvals", store=store), # durable
)
agent.start("Clean up /tmp/cache")Recover outstanding approvals after a restart:
# On process startup, recover outstanding approvals from disk
backend = SlackApproval(channel="#approvals", store=store)
pending = await backend.rehydrate()
print(f"Recovered {len(pending)} pending approvals")The shared DEFAULT_APPROVAL_TIMEOUT (300 seconds) is the single source of truth for the human-in-the-loop wait window across all five backends. Omit store= and behaviour is unchanged — durability is fully opt-in and backward-compatible.
Timeout feedback on the card
When a request expires unanswered, SlackApproval, TelegramApproval, and DiscordApproval edit the original card to “⌛ Timed out — not run” instead of “❌ Denied” (since PR #5916). The buttons are removed either way.
| Outcome on the card | What happened | Tool ran? |
|---|---|---|
| ✅ Approved | Authorised reviewer tapped Allow | Yes |
| ❌ Denied | Authorised reviewer tapped Deny | No |
| ⌛ Timed out — not run | Nobody responded before the deadline | No (fail-closed) |
Discord additionally colours the timed-out embed grey. The card picks the timeout text whenever the decision carries metadata={"timeout": True}, which these backends set when their wait window expires.
ApprovalStore API
| Method | Description |
|---|---|
persist(approval_id, request, *, expires_at) | Store a pending approval; refuses to overwrite resolved rows |
list_pending() | Return outstanding un-expired approvals |
resolve(approval_id, decision) | Record final decision; returns False if already resolved |
expire_overdue() | Mark past-expiry pending rows as expired |
get(approval_id) | Inspect a row for audit/doctor |
purge() | Delete all entries |
What Gets Stored
pending_approvals(
approval_id TEXT PRIMARY KEY,
ts REAL,
expires_at REAL,
request TEXT, -- JSON ApprovalRequest
status TEXT, -- pending | approved | denied | expired
decision TEXT, -- JSON ApprovalDecision when resolved
approver TEXT,
resolved_at REAL
)PersistentApproval entries in approvals.json carry a derived: bool field (default False). When derived=True, the pattern was auto-generated by the reusable command-prefix scope derivation — for example bash:git status * instead of the literal bash:git status -s. Derived patterns match the bare prefix too (bash:git status with no trailing args). Approvals from older approvals.json files default to derived=False and behave as before. See Reusable Approval Scopes.
Secure, Actor-Authorised Approval Backend
The secure (alias: presentation) backend wraps PresentationApprovalHandler and ApprovalStore together into a single ApprovalProtocol adapter — adding actor authorisation and fail-closed behaviour on top of the base durability layer.
Activate via CLI
export PRAISONAI_APPROVAL_ACTORS="telegram:12345,slack:U0123456"
praisonai serve gateway --config gateway.yaml --approval secureActivate via Python
from praisonaiagents import Agent
from praisonai.bots import Bot
agent = Agent(name="assistant", instructions="Be helpful")
bot = Bot("telegram", agent=agent, approval="secure")
bot.run()How it differs from the base ApprovalStore
| Feature | ApprovalStore alone | --approval secure |
|---|---|---|
| Persist to SQLite | ✅ | ✅ |
| Rehydrate on restart | ✅ | ✅ |
| Actor allow-list | ❌ | ✅ (PRAISONAI_APPROVAL_ACTORS) |
| LLM classifier for Allow/Deny | Some backends | ❌ (never uses LLM) |
| Fail-closed on empty allow-list | ❌ | ✅ |
| Approval ID binding | ✅ | ✅ (unguessable UUID) |
Configuration
| Setting | How to set | Required |
|---|---|---|
| Allow-list | PRAISONAI_APPROVAL_ACTORS=telegram:123,slack:U456 | Yes (fail-closed) |
| Store path | PRAISONAI_HOME=/path/to/dir (approvals stored in ` |
/state/approvals.sqlite) | No (default: ~/.praisonai`) |
Rehydration guarantee
Allow-list actors are stored alongside each pending approval. On restart, re-hydrated approvals carry the original allow-list — late Allow/Deny taps after a redeploy are still bound to the original requester.
For gateway requests, authorized_reviewers is persisted and rehydrated by the same SQLite store, so per-request reviewer custody bindings survive a restart. See Per-request reviewer custody.
Best Practices
Use for long-running bots
Telegram, Slack, and Discord bots that restart for deploys benefit most — users won’t lose half-resolved prompts.
Set a sensible TTL
Default eviction is 7 days. Tune ttl_seconds to bound disk growth while keeping an audit trail.
Optional, not required
Console and webhook backends work without a store. Add one only when restart-safety matters.