Skip to content

docs(features): add new page for durable outbound delivery (DurableDelivery / DurableAdapterMixin) #710

Description

@MervinPraison

Context

PR MervinPraison/PraisonAI#2129 (merged 2026-06-22) restored src/praisonai/praisonai/bots/_delivery.py after PR #2054 accidentally deleted it. The module exposes public, user-facing APIs for durable outbound message delivery from PraisonAI bots:

  • DurableDelivery
  • deliver_with_retry
  • deliver_chunked
  • DurableAdapterMixin
  • (companion) OutboundQueue, OutboundEntry from praisonai.bots._outbox

The fact that this regression went undetected for an entire PR cycle is partly because these symbols have zero user documentation in MervinPraison/PraisonAIDocs. The inbound side is documented (docs/features/inbound-journal.mdx), but the outbound side is not. This issue requests a brand-new page covering it.

Decision: NEW content (not an update)

I searched the docs repo for DurableDelivery, deliver_with_retry, DurableAdapterMixin, and outbound delivery topics. Only synced SDK source mirrors hit — no .mdx documentation page exists for outbound durable delivery. So this is a new page, not an update.

The existing docs/features/inbound-journal.mdx is the natural counterpart and should be linked in the "Related" section.

Where the page goes

Per AGENTS.md rules (folder placement):

  • Path: docs/features/outbound-delivery.mdx (default location for AI-agent-authored pages)
  • DO NOT place in docs/concepts/ (human-only)
  • Update docs.json to add the page under the Features group, alongside inbound-journal, inbound-dlq, cross-platform-mirror
  • Suggested sidebar position: right next to inbound-journal so users find the inbound/outbound pair together

SDK source files to READ first (mandatory per AGENTS.md §1.2)

Read these completely before drafting the page — verify every parameter, type, and default against the source:

Symbol Source file (repo: MervinPraison/PraisonAI, branch main)
DurableDelivery, deliver_with_retry, deliver_chunked, MessageSender, UnifiedDelivery src/praisonai/praisonai/bots/_delivery.py
DurableAdapterMixin, setup_durable_delivery, drain_outbox, send_durable src/praisonai/praisonai/bots/_durable_adapter.py
OutboundQueue, OutboundEntry src/praisonai/praisonai/bots/_outbox.py
BackoffPolicy, compute_backoff, is_recoverable_error, sleep_with_abort src/praisonai/praisonai/bots/_resilience.py
OutboundDeliveryProtocol src/praisonaiagents/praisonaiagents/gateway/protocols.py
Public re-exports / lazy loaders src/praisonai/praisonai/bots/__init__.py

Regression test that pins the public surface: src/praisonai/tests/unit/bots/test_delivery_imports.py.

Public symbols and signatures (verified from source — June 2026)

from praisonai.bots import (
    DurableDelivery,
    deliver_with_retry,
    deliver_chunked,
    DurableAdapterMixin,
    OutboundQueue,
    OutboundEntry,
)

DurableDelivery

class DurableDelivery:
    def __init__(
        self,
        outbox: Optional[OutboundQueue] = None,
        adapter: Optional[MessageSender] = None,
        *,
        platform: str = "",
        backoff: Optional[BackoffPolicy] = None,
        max_attempts: int = 3,
    ): ...

    async def send(
        self,
        channel_id: str,
        content: Union[str, Dict[str, Any]],
        *,
        idempotency_key: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
        **send_kwargs,
    ) -> bool: ...

    async def drain_pending(self, limit: Optional[int] = None) -> tuple[int, int]: ...

Behaviour worth documenting:

  • If outbox is None → just retries via deliver_with_retry and returns success/failure.
  • If outbox is set → message is persisted before sending, then delivered, then marked sent or failed. Permanent errors (string-prefixed "Permanent error:") are surfaced via OutboundQueue.mark_failed(..., permanent=True).
  • drain_pending() is for startup recovery — replays anything still pending after a crash.

deliver_with_retry

async def deliver_with_retry(
    adapter: MessageSender,
    channel_id: str,
    content: Union[str, Dict[str, Any]],
    *,
    backoff: Optional[BackoffPolicy] = None,
    max_attempts: int = 3,
    abort_signal: Optional[asyncio.Event] = None,
    platform: str = "",
    **send_kwargs,
) -> tuple[bool, Optional[str]]: ...

Behaviour:

  • Bounded exponential backoff (BackoffPolicy from _resilience.py).
  • Classifies errors via is_recoverable_error(e, platform) — non-recoverable returns immediately with "Permanent error: ...".
  • abort_signal lets callers cancel mid-retry.

deliver_chunked

async def deliver_chunked(
    adapter: MessageSender,
    channel_id: str,
    content: str,
    *,
    max_length: int = 4096,
    preserve_fences: bool = True,
    **send_kwargs,
) -> int: ...

Behaviour: splits long messages with chunk_message and sends each piece; reply_to is only applied to the first chunk.

DurableAdapterMixin

class DurableAdapterMixin:
    def setup_durable_delivery(
        self,
        outbox_path: Optional[str] = None,
        platform: str = "",
        max_attempts: int = 3,
        max_size: int = 50_000,
        ttl_seconds: int = 7 * 86400,  # 7 days
    ) -> None: ...

    async def drain_outbox(self) -> tuple[int, int]: ...

    async def send_durable(
        self,
        channel_id: str,
        content: Union[str, Dict[str, Any]],
        *,
        idempotency_key: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
        **send_kwargs,
    ) -> bool: ...

This is the recommended user-facing entry point — mix it into any custom bot adapter to add durability with one line: self.setup_durable_delivery(outbox_path="~/.praisonai/state/outbox.sqlite", platform="myplatform").

Configuration table to include in the page

Option Type Default Description
outbox_path str | None None SQLite outbox path. None disables durability (falls back to direct send + retry). ~ is expanded; parent dirs auto-created.
platform str "" Platform name. Used for error classification and target prefixing (telegram:CHANNEL).
max_attempts int 3 Max delivery attempts per message before marking failed.
max_size int 50_000 Max entries kept in the outbox. Excess evicted (sent first, then oldest pending).
ttl_seconds int 604_800 (7 days) TTL for sent entries before eviction.
idempotency_key str | None auto-uuid Per-message dedup key. Pass a stable value (e.g. f"{platform}:{message_id}") to prevent double-send on retry.
metadata dict | None None Optional tracking metadata persisted with the entry.
backoff BackoffPolicy | None default policy Exponential-backoff policy from praisonai.bots._resilience.
abort_signal asyncio.Event | None None Cancels in-flight retries (used by deliver_with_retry).

Proposed page structure (follows AGENTS.md template)

---
title: "Outbound Delivery"
sidebarTitle: "Outbound Delivery"
description: "Crash-safe outbound message delivery with persistence, retries, and chunking"
icon: "paper-plane"
---

Outbound Delivery persists messages before sending and retries on failure, so a crashed
bot replays unsent messages on restart instead of losing them.

<hero mermaid graph LR  see color scheme below>

## Quick Start

<Steps>
<Step title="One-line durability for your bot adapter">
  Show `DurableAdapterMixin` + `setup_durable_delivery(outbox_path=...)`
  on a custom adapter, then `send_durable(channel_id, "Hello")`.
</Step>

<Step title="Standalone durable send (no mixin)">
  Show direct `DurableDelivery(outbox, adapter, platform="telegram")`
  + `await delivery.send(channel_id, content, idempotency_key=...)`.
</Step>

<Step title="Replay pending after a crash">
  Show `await delivery.drain_pending()` in your bot's startup hook.
</Step>
</Steps>

## How It Works

<sequenceDiagram: User  Agent  DurableDelivery  OutboundQueue (persist)  Adapter  Platform; on failure: stays in queue; on startup: drain_pending replays>

| Stage | Purpose | What happens |
|---|---|---|
| **enqueue** | Persistence | Message written to SQLite before any send attempt |
| **deliver** | Send with retry | Exponential backoff via `BackoffPolicy`; permanent errors short-circuit |
| **mark_sent / mark_failed** | Bookkeeping | Successful → cleared on TTL; failed → retried on next drain |
| **drain_pending** | Crash recovery | Replays everything not yet marked sent |

## When to use which option

<graph TB diagram choosing between:>
- Bool / no outbox  just `deliver_with_retry` (transient network blips only)
- `DurableAdapterMixin.setup_durable_delivery(outbox_path=...)`  recommended for custom bot adapters
- `DurableDelivery(outbox, adapter, ...)` directly  when you need fine-grained control
- `deliver_chunked`  long messages that exceed platform limits

| Need | Use |
|---|---|
| Retry on flaky network only | `deliver_with_retry` |
| Survive crashes / restarts | `DurableDelivery` with `OutboundQueue` |
| Custom adapter, minimal code | `DurableAdapterMixin.send_durable(...)` |
| Long messages (>4096 chars) | `deliver_chunked` (or rely on `UnifiedDelivery` auto-chunk) |

## Configuration Options

<full table from above>

## Per-platform examples

<Tabs for Telegram, Discord, Slack, WhatsApp  mirror inbound-journal.mdx style,
showing each adapter setting `outbox_path` and calling `send_durable`>

## Common Patterns

### Pattern 1: Pair with `InboundJournal` for full inbound + outbound durability
### Pattern 2: Idempotency keys to survive duplicate webhooks
### Pattern 3: Startup drain in the bot's lifecycle hook
### Pattern 4: Chunking long agent replies

## Best Practices

<AccordionGroup>
- Use absolute, persistent path for `outbox_path` (not /tmp)
- Set a stable `idempotency_key` (e.g. `f"{platform}:{message_id}"`)
- Always call `drain_outbox()` on startup
- Tune `max_attempts` and `BackoffPolicy` for your platform's rate limits
</AccordionGroup>

## Related

<CardGroup cols={2}>
<Card title="Inbound Journal" href="/docs/features/inbound-journal">Inbound side: dedup + crash-safe replay</Card>
<Card title="Inbound DLQ" href="/docs/features/inbound-dlq">Failure handling on the agent side</Card>
</CardGroup>

Mandatory style/quality requirements (from AGENTS.md)

  • ✅ Hero Mermaid diagram with the standard color scheme (#8B0000, #189AB4, #10B981, #F59E0B, #6366F1, white text, #7C90A0 stroke).
  • ✅ User-focused / non-developer tone — "Is it really this easy?" feeling. No SDK-style API dump.
  • ✅ Agent-centric: open with how an Agent author wires this in, not internal class diagrams.
  • ✅ Use simple imports only: from praisonai.bots import DurableDelivery, DurableAdapterMixin — never reach into praisonai.bots._delivery.
  • ✅ Every code example must run as-is (real imports, realistic but minimal data, no your-key-here placeholders).
  • ✅ Use <Steps>, <Tabs>, <AccordionGroup>, <CardGroup> Mintlify components.
  • ✅ A "How users will interact with this in real scenarios" flow diagram (sequence diagram showing webhook → durable send → crash → replay → user eventually receives reply).
  • ✅ A decision diagram for "which option do I use?" because there are 4 entry points (deliver_with_retry, deliver_chunked, DurableDelivery, DurableAdapterMixin).
  • ✅ Frontmatter icon: "paper-plane" (outbound) is a good fit; alternative: "truck" or "share".
  • ❌ Do not document internal helpers (UnifiedDelivery, MessageSender Protocol, _send_streamed, etc.) — those are SDK-internal; the auto-generated SDK reference covers them.
  • ❌ Do not modify docs/concepts/ (human-only per AGENTS.md §1.8).

docs.json update

Add the new page under the Features group, ideally adjacent to inbound-journal:

{
  "group": "Features",
  "pages": [
    "...",
    "docs/features/inbound-journal",
    "docs/features/outbound-delivery",   // ← new
    "docs/features/inbound-dlq",
    "..."
  ]
}

Validate docs.json as JSON after editing (AGENTS.md §1.9 rule 8).

Acceptance checklist

  • New file docs/features/outbound-delivery.mdx exists, follows the template above
  • All parameter types/defaults match _delivery.py, _durable_adapter.py, _outbox.py exactly
  • Hero diagram + sequence diagram + decision diagram present, all using the standard color palette
  • At least 4 platform examples in <Tabs> (Telegram, Discord, Slack, WhatsApp)
  • Cross-linked from docs/features/inbound-journal.mdx "Related" section
  • docs.json updated and valid JSON
  • No forbidden phrases ("In this section, we will...", "Please note that...", etc.)
  • PR branch claude/admiring-euler-1rw6s0 per the task instructions

Working branches

Per the task spec:

  • PraisonAI: claude/confident-fermat-1rw6s0 (no code changes needed for this docs issue)
  • PraisonAIDocs: claude/admiring-euler-1rw6s0 (where the new page should land)

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclaudeTrigger Claude Code analysisdocumentationImprovements or additions to documentationenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions