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.

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

Diagram

Yes

Allow/Deny

No

Agent tool call

Approval required?

ApprovalStore persist

Channel prompt

resolve + audit

Execute tool

Quick Start

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

    On startup, outstanding pending approvals are re-hydrated. Users can tap Allow/Deny on messages sent before the restart.


How It Works

Diagram
UserApprovalStorePresentationApprovalAgentUserApprovalStorePresentationApprovalAgentgateway restartsrisky tool callpersist(approval_id, request)inline buttons / promptlist_pending() on startuplate Allow tapresolve(approval_id, decision)

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().

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

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

Transports

Slack

Telegram

Discord

Webhook

HTTP

DurableApprovalMixin

ApprovalStore SQLite

resolve + audit

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 cardWhat happenedTool ran?
✅ ApprovedAuthorised reviewer tapped AllowYes
❌ DeniedAuthorised reviewer tapped DenyNo
⌛ Timed out — not runNobody responded before the deadlineNo (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

MethodDescription
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

bash
export PRAISONAI_APPROVAL_ACTORS="telegram:12345,slack:U0123456"
praisonai serve gateway --config gateway.yaml --approval secure

Activate via Python

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

FeatureApprovalStore alone--approval secure
Persist to SQLite✅✅
Rehydrate on restart✅✅
Actor allow-list❌✅ (PRAISONAI_APPROVAL_ACTORS)
LLM classifier for Allow/DenySome backends❌ (never uses LLM)
Fail-closed on empty allow-list❌✅
Approval ID binding✅✅ (unguessable UUID)

Configuration

SettingHow to setRequired
Allow-listPRAISONAI_APPROVAL_ACTORS=telegram:123,slack:U456Yes (fail-closed)
Store pathPRAISONAI_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.


Related