> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Durable Outbound Delivery

> Persist outbound bot messages with retry, idempotency, and crash-safe drain on restart

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent

agent = Agent(name="bot-agent", instructions="Reply on messaging channels.")
agent.start("Send the user a confirmation once the job completes.")
```

Durable Delivery persists every outbound bot message to a SQLite outbox so retries, restarts, and platform outages never drop a reply. For **permanently** dead targets — bot kicked, chat deleted — see [Dead Target Registry](/docs/features/dead-target-registry), which suppresses doomed sends instead of queuing them.

<Note>
  This page covers **outbound** durability (`DurableDelivery` / `OutboundQueue`). For **inbound** durability — which is now on by default for all gateway/bot runs — see [Inbound Journal](/docs/features/inbound-journal) and [Inbound DLQ](/docs/features/inbound-dlq).
</Note>

<Note>
  **Durable Delivery vs Outbound Resilience.** Durable Delivery is the heavy option — a SQLite outbox that survives crashes and lets you `send_durable()` explicitly. The lighter [Outbound Resilience](/docs/features/outbound-resilience) is now on by default in every bot adapter and retries transient failures without changing any code. Use Outbound Resilience for typical "don't drop replies on a 429" scenarios; reach for Durable Delivery when you also need to survive process crashes.
</Note>

The user expects a bot reply; durable delivery queues outbound messages, retries transient failures, and drains after restarts.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Durable Outbound Delivery"
        Agent[🤖 Agent] --> Send[📤 send_durable]
        Send --> Queue[💾 OutboundQueue]
        Queue --> Retry[🔁 deliver_with_retry]
        Retry -->|success| Sent[✅ mark_sent]
        Retry -->|transient fail| Later[⏳ drain_pending]
        Later --> Retry
    end

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef storage fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef success fill:#10B981,stroke:#7C90A0,color:#fff
    classDef pending fill:#F59E0B,stroke:#7C90A0,color:#fff

    class Agent agent
    class Send,Retry process
    class Queue storage
    class Sent success
    class Later pending
```

## Quick Start

<Steps>
  <Step title="Easiest path — DurableAdapterMixin">
    Add three lines to any existing adapter and every `send_durable()` call is crash-safe:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    import os
    from praisonaiagents import Agent
    from praisonai.bots import TelegramBot

    # TelegramBot already supports durable delivery via DurableAdapterMixin
    agent = Agent(name="assistant", instructions="Help users")
    bot = TelegramBot(
        token=os.getenv("TELEGRAM_BOT_TOKEN"),
        agent=agent,
    )

    # Replay anything queued before the last crash.
    # Also fires automatically on channel recovery — no need to wire it yourself.
    # Manual call is only needed for adapters not driven by ChannelSupervisor.
    await bot.drain_outbox()

    # Send with durability
    await bot.send_durable(
        channel_id="12345",
        content="Hello! I'm crash-safe.",
        idempotency_key="welcome-msg-12345",
    )
    ```
  </Step>

  <Step title="Configure manually with DurableDelivery">
    For full control, wire up `OutboundQueue` and `DurableDelivery` directly:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    import os
    from praisonaiagents import Agent
    from praisonai.bots import OutboundQueue, DurableDelivery, TelegramBot

    outbox = OutboundQueue(path="~/.praisonai/state/outbox.sqlite")
    adapter = TelegramBot(
        token=os.getenv("TELEGRAM_BOT_TOKEN"),
        agent=Agent(name="assistant", instructions="Help users"),
    )
    delivery = DurableDelivery(
        outbox, adapter, platform="telegram",
        # mark_recovered=True,  # opt-in: prefix crash-recovered re-sends with a "possible duplicate" marker
    )

    # On startup: replay anything queued before the last crash
    succeeded, failed = await delivery.drain_pending()

    # Send with durability — idempotent if you reuse the key
    success = await delivery.send(
        channel_id="12345",
        content="Hello, world!",
        idempotency_key="msg-123",
    )
    ```
  </Step>
</Steps>

***

## How It Works

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Bot as 🤖 Bot Adapter
    participant Queue as 💾 OutboundQueue
    participant Platform as 📱 Platform API

    Bot->>Queue: enqueue(key, channel, payload)
    Queue-->>Bot: tracking key
    Bot->>Platform: attempt delivery
    alt success
        Platform-->>Bot: 200 OK
        Bot->>Queue: mark_sent(key)
    else transient failure
        Platform-->>Bot: 429 (retry_after: 30)
        Bot->>Queue: mark_failed(key, permanent=False)
        Note over Queue: status = 'failed'
        Bot->>Queue: drain_pending()
        Note over Queue: drain gates by max(backoff, server_retry_after)
        Queue->>Platform: retry after 30s
        Platform-->>Queue: 200 OK
        Queue->>Queue: mark_sent(key)
    else permanent failure
        Platform-->>Bot: 400 Bad Request
        Bot->>Queue: mark_failed(key, permanent=True)
        Note over Queue: status = 'permanent_failure'
    end
```

Each message moves through statuses: `pending` → `sending` → `sent` (or `failed` / `permanent_failure`).

### State Machine

The outbox tracks six statuses: `pending`, `sending`, `recovered`, `sent`, `failed`, and `permanent_failure`.

On restart, stale `sending` entries transition to **`recovered`** instead of `pending`. This preserves the information that the send was in-flight when the crash occurred.

```
status = 'sending'  (crash)  ──►  status = 'recovered'  (on restart)
```

`recovered` entries are retryable like `pending`, but when a reconciler is supplied to `drain()`, they are offered to it first — allowing adapters that can check the platform to confirm delivery and avoid re-sending an already-delivered message (**effectively-once**). Without a reconciler, `recovered` entries are re-sent as normal (at-least-once, unchanged behaviour).

<Note>
  **Surfaced as `SendResult.status`.** When an adapter routes a send through the outbox, the [`SendResult`](/docs/features/bot-platform-adapter#branching-on-delivery-outcome) it hands back carries `status == "queued"` — so a caller can tell "will land later" from "landed now" without inspecting private outbox state. After a crash-recovered re-drain that re-sends without positive reconciliation, the surfaced status is `"duplicate"` — an honest at-least-once signal. `DurableDelivery` itself still returns its historical value; only the adapter's `SendResult` gains the typed status.
</Note>

<Note>
  Status-update writes (`mark_sent`, `mark_failed`, `_claim_entry`) now call `conn.commit()`. Without this, a terminal status could be lost on a crash, causing an already-delivered message to be redelivered. This is a reliability fix — no API change is required.
</Note>

### Age-gated dead-lettering (attempts + wall-clock)

`permanent_failure` is now a two-condition decision, so a brief channel outage no longer silently drops deliverable messages.

An entry moves to `permanent_failure` when **either**:

* The error class is **known-permanent** (`"credential"` or `"permanent_target"`) — short-circuits immediately regardless of age.
* **Both** `attempts >= max_attempts` **and** the entry has been in the queue for `>= dead_letter_min_age` (default 6h).

Otherwise the entry stays retryable and the next `drain()` reschedules it under the normal capped backoff.

The default 6-hour floor is far longer than any realistic channel incident and well under the 7-day retention TTL, so most operators need no config change — transient outages simply keep retrying until the channel recovers.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start([drain sees exhausted entry]) --> ErrCheck{Known-permanent<br/>error class?}
    ErrCheck -->|credential / permanent_target| DL[💀 permanent_failure]
    ErrCheck -->|recoverable| AgeCheck{Age ≥ dead_letter_min_age?}
    AgeCheck -->|Yes| DL
    AgeCheck -->|No| Retry[🔁 Reschedule under backoff]

    classDef start fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef terminal fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef retry fill:#10B981,stroke:#7C90A0,color:#fff

    class Start start
    class ErrCheck,AgeCheck check
    class DL terminal
    class Retry retry
```

**Restoring legacy behaviour** — pass `dead_letter_min_age=0` if you rely on attempt-count-only quarantine (e.g. poison-message unit tests):

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonai.bots import OutboundQueue

# Attempt-only dead-lettering (pre-#3521 behaviour)
outbox = OutboundQueue(
    path="~/.praisonai/state/outbox.sqlite",
    max_attempts=5,
    dead_letter_min_age=0,
)
```

**Custom policy** — inject any `DeadLetterPolicyProtocol` implementation for one-off decisions:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.gateway import AttemptAndAgeDeadLetterPolicy
from praisonai.bots import OutboundQueue

outbox = OutboundQueue(
    path="~/.praisonai/state/outbox.sqlite",
    dead_letter_policy=AttemptAndAgeDeadLetterPolicy(
        max_attempts=10,
        min_age_seconds=24 * 3600,  # allow a full day of retries
    ),
)
```

Introduced in [PraisonAI PR #3521](https://github.com/MervinPraison/PraisonAI/pull/3521).

***

## Effectively-Once Delivery

Adapters that can confirm whether a prior send actually landed can upgrade durable delivery from at-least-once to effectively-once.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Adapter as 🤖 Adapter
    participant Queue as 💾 OutboundQueue
    participant Provider as 📱 Platform

    Note over Queue: status = 'sending'
    Adapter--xProvider: 💥 crash mid-send
    Note over Queue: on restart → 'recovered'
    Adapter->>Queue: drain_pending() (with reconciler)
    Queue->>Adapter: reconciler(entry)
    Adapter->>Provider: was_delivered(idempotency_key)
    alt Provider says delivered
        Provider-->>Adapter: True
        Adapter-->>Queue: True → mark_sent (NO resend)
    else Provider says not delivered
        Provider-->>Adapter: False
        Queue->>Provider: resend
    end
```

<Steps>
  <Step title="Declare the capability">
    Set `reconciles_unknown_send=True` on `PlatformCapabilities`:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents.bots import PlatformCapabilities

    class MyAdapter(DurableAdapterMixin):
        platform_capabilities = PlatformCapabilities(
            reconciles_unknown_send=True,
        )
    ```
  </Step>

  <Step title="Implement was_delivered">
    Add an async method that checks the platform for a prior send:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    async def was_delivered(self, idempotency_key: str) -> bool:
        status = await my_provider.get_message_status(idempotency_key)
        return status == "sent"
    ```
  </Step>

  <Step title="Call drain_pending() at startup">
    `DurableDelivery` auto-wires the reconciler from the adapter's capability — no extra configuration required:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents.bots import PlatformCapabilities
    from praisonai.bots._adapter_base import DurableAdapterMixin


    class AcmeAdapter(DurableAdapterMixin):
        platform_capabilities = PlatformCapabilities(
            reconciles_unknown_send=True,
        )

        async def was_delivered(self, idempotency_key: str) -> bool:
            status = await acme_client.messages.lookup(idempotency_key)
            return status.delivered

        async def send_message(self, channel_id: str, content: str) -> None:
            await acme_client.messages.send(channel_id, content)


    # DurableDelivery picks up the reconciler automatically.
    succeeded, failed = await adapter.drain_outbox()
    ```
  </Step>
</Steps>

***

## Choosing the Right Primitive

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start([What do I need?]) --> Q1{Survive crashes\nand restarts?}
    Q1 -->|Yes, I have an adapter| Mixin[DurableAdapterMixin]
    Q1 -->|Yes, full control| DD[DurableDelivery]
    Q1 -->|No persistence needed| Q2{Message too\nlong for platform?}
    Q2 -->|Yes| Chunk[deliver_chunked]
    Q2 -->|No| Q3{Need retry on\ntransient 429/503?}
    Q3 -->|Yes, on by default| Resilience[OutboundResilienceMixin]
    Q3 -->|Manual control| Retry[deliver_with_retry]

    classDef decision fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff

    class Q1,Q2,Q3 decision
    class Mixin,DD,Chunk,Resilience,Retry result
```

| I need to…                                                  | Use                                                                                         |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Auto-retry transient failures — no config, works everywhere | [`OutboundResilienceMixin`](/docs/features/outbound-resilience) (on by default in every adapter) |
| Manual retry with explicit backoff policy                   | `deliver_with_retry()`                                                                      |
| Send a message that may exceed platform length limits       | `deliver_chunked()`                                                                         |
| Survive process crashes / platform outages with replay      | `DurableDelivery` or `DurableAdapterMixin`                                                  |
| Build a brand-new custom adapter with durability built in   | `DurableAdapterMixin`                                                                       |

***

## Configuration Options

### `OutboundQueue`

SQLite-backed outbox. All parameters after `path` are keyword-only.

| Parameter             | Type                               | Default           | Description                                                                                                                                                                                                                                                                                                                                                                                   |
| --------------------- | ---------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `path`                | `str \| Path`                      | *(required)*      | SQLite file path. Parent dirs are created automatically.                                                                                                                                                                                                                                                                                                                                      |
| `max_size`            | `int`                              | `50_000`          | Max entries kept. Oldest sent entries are evicted when exceeded.                                                                                                                                                                                                                                                                                                                              |
| `ttl_seconds`         | `int`                              | `604800` (7 days) | Sent entries older than this are evicted.                                                                                                                                                                                                                                                                                                                                                     |
| `max_attempts`        | `int`                              | `5`               | Max delivery attempts before a message is eligible for permanent failure. Since [PR #3521](https://github.com/MervinPraison/PraisonAI/pull/3521), exhausting attempts alone is no longer enough to dead-letter a *transient* failure — the entry must also satisfy `dead_letter_min_age`. A known-permanent error (credentials, permanently-invalid target) still short-circuits immediately. |
| `dead_letter_min_age` | `float` (seconds)                  | `21600` (6h)      | Minimum wall-clock age since first receipt before an attempt-exhausted transient failure is marked `permanent_failure`. Set to `0` to restore the pre-#3521 attempt-only behaviour.                                                                                                                                                                                                           |
| `dead_letter_policy`  | `DeadLetterPolicyProtocol \| None` | `None`            | Optional custom `praisonaiagents.gateway.DeadLetterPolicyProtocol` implementation. When supplied, overrides `max_attempts` + `dead_letter_min_age`.                                                                                                                                                                                                                                           |
| `backoff`             | `BackoffPolicy`                    | `BackoffPolicy()` | Retry backoff configuration.                                                                                                                                                                                                                                                                                                                                                                  |
| `ordering`            | `"strict" \| "best_effort"`        | `"best_effort"`   | Per-conversation FIFO gate. `strict` holds later same-lane messages until the head reaches `sent` or `permanent_failure`. See [Outbound Ordering](/docs/features/outbound-ordering).                                                                                                                                                                                                               |

<Note>
  On first open, older `outbox.sqlite` files are auto-migrated to add a `lane_key` column, backfilled to `target`. No user action required — existing outbox files continue to work.
</Note>

#### `OutboundQueue.drain()` signature

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
async def drain(
    self,
    sender: Callable[[str, Dict[str, Any]], Awaitable[bool]],
    limit: Optional[int] = None,
    *,
    reconciler: Optional[Callable[[OutboundEntry], Awaitable[bool]]] = None,
) -> Tuple[int, int]:
```

| Parameter    | Type                     | Default      | Description                                                                                                                                                                                      |
| ------------ | ------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sender`     | `async callable`         | *(required)* | Async callable that sends the message.                                                                                                                                                           |
| `limit`      | `int \| None`            | `None`       | Max entries to drain in this call.                                                                                                                                                               |
| `reconciler` | `async callable \| None` | `None`       | Called only for `recovered` entries. Returns `True` → mark sent without re-dispatch (effectively-once). Returns `False` → re-send as normal. Raises → falls back to re-send (logged at WARNING). |

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonai.bots import OutboundQueue

outbox = OutboundQueue(
    path="~/.praisonai/state/outbox.sqlite",
    max_size=10_000,
    ttl_seconds=3 * 86400,  # 3 days
    max_attempts=3,
)
```

#### `OutboundQueue.enqueue()` signature

`enqueue()` accepts an optional keyword-only `lane_key` for per-conversation grouping. It defaults to `target`, so messages to the same chat share a lane under `ordering="strict"`.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
async def enqueue(
    self,
    idempotency_key: str,
    target: str,
    payload: Dict[str, Any],
    metadata: Optional[Dict[str, Any]] = None,
    *,
    lane_key: Optional[str] = None,   # defaults to target
) -> str:
    ...
```

| Parameter  | Type          | Default             | Description                                                                                                                                             |
| ---------- | ------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `lane_key` | `str \| None` | `None` (= `target`) | Custom conversation grouping. Pass a thread id or user id to order sends within a shared channel. See [Outbound Ordering](/docs/features/outbound-ordering). |

#### `OutboundQueue.status_for()` signature

`status_for()` returns the current status of the entry for an idempotency key.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
def status_for(self, idempotency_key: str) -> Optional[str]:
    ...
```

| Parameter         | Type  | Description                              |
| ----------------- | ----- | ---------------------------------------- |
| `idempotency_key` | `str` | The stable key used at `enqueue()` time. |

**Returns** — the current entry status (`"pending"`, `"sending"`, `"recovered"`, `"sent"`, `"failed"`, or `"permanent_failure"`), or `None` if no entry exists for that key.

Use it to tell an already-delivered duplicate (status `"sent"`) apart from a genuine delivery failure after a drain — so a deduplicated re-fire is reported as a suppressed success rather than a spurious failure.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonai.bots import OutboundQueue

outbox = OutboundQueue(path="~/.praisonai/state/gateway_outbox.sqlite")
await outbox.enqueue(
    idempotency_key="daily-brief-2026-07-20",
    target="telegram:-100123",
    payload={"text": "Morning brief ready."},
)
await outbox.drain(sender)

if outbox.status_for("daily-brief-2026-07-20") == "sent":
    print("Delivered (or suppressed as a duplicate of a prior send).")
```

### `DurableDelivery`

Wraps an `OutboundQueue` and an adapter to provide a simple `.send()` / `.drain_pending()` API.

| Parameter        | Type             | Default           | Description                                                                                                                                                                                                                                                                                                            |
| ---------------- | ---------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `outbox`         | `OutboundQueue`  | *(required)*      | The outbound queue to persist messages.                                                                                                                                                                                                                                                                                |
| `adapter`        | adapter instance | *(required)*      | Bot adapter with a `send_message(channel_id, content)` method.                                                                                                                                                                                                                                                         |
| `platform`       | `str`            | `""`              | Platform name for platform-aware error classification.                                                                                                                                                                                                                                                                 |
| `backoff`        | `BackoffPolicy`  | `BackoffPolicy()` | Retry backoff configuration.                                                                                                                                                                                                                                                                                           |
| `max_attempts`   | `int`            | `3`               | Max delivery attempts.                                                                                                                                                                                                                                                                                                 |
| `mark_recovered` | `bool`           | `False`           | When `True`, a crash-recovered message re-sent without positive reconciliation is prefixed with a visible "possible duplicate after restart" marker instead of being re-delivered silently. See [Labelling the at-least-once fallback](/docs/docs/features/bot-platform-capabilities#labelling-the-at-least-once-fallback). |

### `DurableAdapterMixin.setup_durable_delivery()`

Call once in your adapter's `__init__` to wire up the outbox.

| Parameter        | Type          | Default           | Description                                                                                                                                                                                                                                                |
| ---------------- | ------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `outbox_path`    | `str \| None` | `None`            | Path to SQLite outbox. `None` disables durability.                                                                                                                                                                                                         |
| `platform`       | `str`         | `""`              | Platform name for error classification.                                                                                                                                                                                                                    |
| `max_attempts`   | `int`         | `3`               | Max delivery attempts per message.                                                                                                                                                                                                                         |
| `max_size`       | `int`         | `50_000`          | Max messages in outbox.                                                                                                                                                                                                                                    |
| `ttl_seconds`    | `int`         | `604800` (7 days) | TTL for sent messages.                                                                                                                                                                                                                                     |
| `mark_recovered` | `bool`        | `False`           | When `True`, label crash-recovered re-sends with a "possible duplicate after restart" marker instead of re-delivering silently. See [Labelling the at-least-once fallback](/docs/docs/features/bot-platform-capabilities#labelling-the-at-least-once-fallback). |

### `deliver_with_retry()`

Bounded retry without persistence — on a recoverable failure, the delay is the **server-mandated wait** (`server_retry_after(err)`) when present, otherwise `compute_backoff(policy, attempt)`.

| Parameter        | Type                    | Default                         | Description                                                                                                                                                                                                                        |
| ---------------- | ----------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `send_func`      | `async callable`        | *(required)*                    | Async callable to execute (the send operation).                                                                                                                                                                                    |
| `policy`         | `BackoffPolicy`         | `BackoffPolicy(max_attempts=3)` | Retry backoff configuration.                                                                                                                                                                                                       |
| `is_recoverable` | `callable \| None`      | `None`                          | Function to classify errors as transient. Defaults to `is_recoverable_error(e, platform)`.                                                                                                                                         |
| `platform`       | `str`                   | `""`                            | Platform name for platform-specific error rules.                                                                                                                                                                                   |
| `rate_limiter`   | `Optional[RateLimiter]` | `None`                          | When supplied, a server-mandated wait (Telegram `retry_after`, HTTP `Retry-After`) triggers `await rate_limiter.penalise(channel_id, delay)` so subsequent sends to the same channel hold off until the platform's window elapses. |
| `parked_store`   | `Any \| None`           | `None`                          | Optional DLQ for failed sends.                                                                                                                                                                                                     |
| `reply_data`     | `dict \| None`          | `None`                          | Optional metadata for DLQ storage.                                                                                                                                                                                                 |

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonai.bots._delivery import deliver_with_retry
from praisonai.bots._rate_limit import RateLimiter
from praisonai.bots._resilience import BackoffPolicy

limiter = RateLimiter.for_platform("telegram")
ok, err = await deliver_with_retry(
    send_fn, channel_id="telegram-chat-12345",
    rate_limiter=limiter,
    policy=BackoffPolicy(initial_ms=2000, max_ms=30000, max_attempts=5),
    platform="telegram",
)
```

### `server_retry_after()`

Extracts a server-mandated wait (seconds) from an error or response. Used internally by `deliver_with_retry`, `ConnectionMonitor.record_error`, and `OutboundQueue.drain` to honour explicit throttle signals over the policy backoff.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonai.bots._resilience import server_retry_after

# Telegram RetryAfter
wait = server_retry_after(err)          # -> 30.0

# HTTP Retry-After header (seconds or HTTP-date)
wait = server_retry_after(http_err)     # -> 5.0 or seconds-until-date

# No hint present
wait = server_retry_after(generic_err)  # -> None
```

| Source                | Where it's read                                              | Notes                                                                                                                         |
| --------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| Telegram `RetryAfter` | `err.retry_after` attribute                                  | python-telegram-bot exposes this directly.                                                                                    |
| Telegram raw API 429  | `err.parameters["retry_after"]`                              | Raw API shape.                                                                                                                |
| HTTP `Retry-After`    | `err.headers`, `err.response.headers`, or `err.resp.headers` | Integer seconds **or** HTTP-date (parsed via `email.utils.parsedate_to_datetime`; missing tz treated as UTC; clamped `>= 0`). |
| Text fallback         | regex on `str(err)`                                          | Matches `retry after <n>` / `retry_after: <n>`.                                                                               |

Returns `None` when no hint is present — callers fall back to the policy backoff.

### `deliver_chunked()`

Splits a long message at paragraph boundaries and sends each chunk separately. Returns the number of chunks sent.

| Parameter         | Type             | Default      | Description                                                     |
| ----------------- | ---------------- | ------------ | --------------------------------------------------------------- |
| `adapter`         | adapter instance | *(required)* | Bot adapter with `send_message(channel_id, content)`.           |
| `channel_id`      | `str`            | *(required)* | Target channel.                                                 |
| `content`         | `str`            | *(required)* | Message text to split and send.                                 |
| `max_length`      | `int`            | `4096`       | Max characters per chunk (Telegram limit is 4096).              |
| `preserve_fences` | `bool`           | `True`       | Keep code fence blocks intact even if they exceed `max_length`. |

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonai.bots._chunk import chunk_message

# chunk_message is the underlying splitter
chunks = chunk_message(long_text, max_length=4096, preserve_fences=True)
for chunk in chunks:
    await adapter.send_message(channel_id, chunk)
```

### `BackoffPolicy`

Controls retry timing for both `deliver_with_retry` and `OutboundQueue.drain`.

| Attribute      | Type    | Default   | Description                          |
| -------------- | ------- | --------- | ------------------------------------ |
| `initial_ms`   | `float` | `2000.0`  | Initial delay in milliseconds.       |
| `max_ms`       | `float` | `30000.0` | Maximum delay in milliseconds.       |
| `factor`       | `float` | `1.8`     | Multiplicative factor per attempt.   |
| `jitter`       | `float` | `0.25`    | Random jitter fraction (0.0–1.0).    |
| `max_attempts` | `int`   | `0`       | Max retry attempts. `0` = unlimited. |

***

## Idempotency & When the Outbox Drains

### Idempotency Keys

Every message has an `idempotency_key` — a UUID generated automatically if you omit it. Reusing the same key for the same logical message prevents double-sends across retries.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# Webhook-triggered reply: derive key from inbound message ID
inbound_id = webhook_payload["message_id"]
await delivery.send(
    channel_id=chat_id,
    content=reply_text,
    idempotency_key=f"reply-{inbound_id}",
)
```

If the webhook is redelivered and `send()` is called again with the same key, the outbox skips the enqueue (SQLite `UNIQUE` constraint) and marks the existing row sent.

### When the Outbox Drains

Held replies go out on **three** triggers, so operators can reason about *when* a queued message is actually re-attempted:

1. **Adapter startup** — replays anything queued before the last crash (`drain_pending()` / `drain_outbox()`).
2. **Channel recovery** *(new — Issue #4043)* — when `ChannelSupervisor` observes a channel come back after a recoverable outage (network blip, transient API error, stale socket), it fires `drain_outbox()` in the background so held replies go out promptly. Applications do not have to call anything.
3. **Lazy on next inbound turn** — the next `chat()` turn also opportunistically drains.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "When the outbox drains"
        Start[🚀 Adapter startup] --> Drain[💾 drain_outbox]
        Recover[🔁 Channel recovers] --> Drain
        Chat[💬 Next chat turn] --> Drain
        Drain --> Send[📤 Held replies go out]
    end

    classDef trigger fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef process fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff

    class Start,Recover,Chat trigger
    class Drain process
    class Send result
```

Call `drain_pending()` once at adapter startup to replay anything that was queued before the last crash:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# On adapter startup
succeeded, failed = await delivery.drain_pending()
# Log output: "Drained outbox: 3 sent, 0 failed"

# Or with the mixin:
succeeded, failed = await adapter.drain_outbox()
```

The drain replays oldest messages first and skips messages that have exceeded `max_attempts`. The retry gate is `max(compute_backoff(policy, attempts + 1), server_retry_after(stored_err))` — the platform's mandated wait survives across process restarts.

<Note>
  The hint is recovered from the **stored error string**, not the live exception. Only hints that survive `str(err)` (python-telegram-bot `RetryAfter` repr, HTTP headers folded into the error message, text-form "retry after N") are honoured on drain.
</Note>

### Recovery-triggered re-drain (Issue #4043)

*Why did my held reply just go out on its own?* When a channel drops on a transient outage, the outbox deliberately **holds** deliverable replies instead of dropping them. Before, those replies sat undelivered until the next inbound `chat()` turn or a process restart. Now `ChannelSupervisor` re-drains that platform's outbox the moment the channel reconnects — see [Channel supervision → Recovery-triggered outbox re-drain](/docs/features/gateway-channel-supervision#recovery-triggered-outbox-re-drain).

<AccordionGroup>
  <Accordion title="Recovery only — not a clean start">
    The re-drain fires only when the channel comes back from **at least one** recoverable failure (`monitor.attempt > 0`). A cold, clean first start does **not** trigger it — the startup drain still covers that path.
  </Accordion>

  <Accordion title="Background-scheduled — never blocks recovery">
    It launches as an `asyncio.create_task(...)` and is not awaited by the supervision loop, so a slow or large re-drain can never delay the channel coming back online.
  </Accordion>

  <Accordion title="Duck-typed and best-effort">
    The supervisor resolves `drain_outbox` on the supervised object first, then falls back to `bot.adapter.drain_outbox`. No hook → silent no-op. An exception is logged at `WARNING` and swallowed — the outbox's own attempt-and-age dead-letter policy still governs those messages, so a failed re-drain never wedges supervision.
  </Accordion>

  <Accordion title="Bounded and idempotent">
    The re-drain is bounded by the outbox's existing attempt-and-age policy and is safe to call more than once because the outbox itself dedupes on `idempotency_key`.
  </Accordion>
</AccordionGroup>

Log lines from the merged code:

```
# success (only when something moved):
Channel '<name>' recovered: re-drained outbox (<S> sent, <F> failed)

# failure (swallowed — retried on the next drain):
Channel '<name>' outbox re-drain on recovery failed (will retry on next drain): <exc>
```

Introduced in [PraisonAI PR #4046](https://github.com/MervinPraison/PraisonAI/pull/4046) (fixes [#4043](https://github.com/MervinPraison/PraisonAI/issues/4043)).

***

## Best Practices

<AccordionGroup>
  <Accordion title="Set reconciles_unknown_send=True only if you can answer the question reliably">
    A flaky `was_delivered` that returns `False` for an already-delivered message will cause a duplicate send. A reconciler that raises falls back to at-least-once re-send (safe, but logged at WARNING). Only opt in when your platform provides a reliable message-status API.
  </Accordion>

  <Accordion title="Don't rely on supports_idempotency_token alone for dedupe">
    `supports_idempotency_token=True` is informational only — the outbox does not forward the token on resend for most adapters. Adapters relying on provider-side deduplication should also set `reconciles_unknown_send=True` to get effectively-once delivery.

    **Exception — AgentMail.** `AgentMailBot.send_message` declares an explicit `idempotency_key` parameter, so the durable layer forwards the persisted outbox key on every attempt (including a post-crash resend). AgentMail deduplicates that resend at the provider for 24h, giving AgentMail provider-level dedup even without a `was_delivered` reconciler. See [Duplicate protection on retries](/docs/features/email-bot#duplicate-protection-on-retries).
  </Accordion>

  <Accordion title="Always set platform= for accurate error classification">
    The `is_recoverable_error()` function checks platform-specific patterns (e.g., Telegram's HTTP 409 conflict, rate-limit "retry after" responses) when a platform name is provided. Without it, only generic patterns are checked and some transient errors may be misclassified as permanent.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    delivery = DurableDelivery(outbox, adapter, platform="telegram")
    ```
  </Accordion>

  <Accordion title="Use a stable idempotency_key derived from the inbound message">
    When bridging a webhook to an outbound reply, derive the key from the inbound message ID. This ensures webhook redeliveries don't produce duplicate outbound sends.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    # ✅ Good: stable key tied to the inbound event
    await delivery.send(
        channel_id=chat_id,
        content=reply,
        idempotency_key=f"reply-{inbound_message_id}",
    )

    # ❌ Bad: new UUID every call — no deduplication across retries
    import uuid
    await delivery.send(channel_id=chat_id, content=reply, idempotency_key=str(uuid.uuid4()))
    ```
  </Accordion>

  <Accordion title="Keep the outbox on persistent local disk">
    Store the outbox on a persistent, local filesystem path — not `/tmp` and not a Docker tmpfs. The default suggestion is `~/.praisonai/state/outbox.sqlite`.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    # ✅ Good: persistent path
    outbox = OutboundQueue(path="~/.praisonai/state/outbox.sqlite")

    # ❌ Bad: lost on restart or container rebuild
    outbox = OutboundQueue(path="/tmp/outbox.sqlite")
    ```
  </Accordion>

  <Accordion title="Call drain_pending() exactly once per adapter start">
    Multiple concurrent drainers fight over the same rows via SQLite's `status = 'sending'` claim mechanism. A 5-minute claim timeout releases stale claims, but concurrent drainers still produce redundant work and log noise.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    # ✅ Good: single drain at startup
    async def on_start():
        succeeded, failed = await delivery.drain_pending()

    # ❌ Bad: two drainers in separate tasks
    asyncio.create_task(delivery.drain_pending())
    asyncio.create_task(delivery.drain_pending())
    ```
  </Accordion>

  <Accordion title="Only lower dead_letter_min_age with a specific reason">
    The 6-hour default is calibrated so a routine channel outage (Telegram 429 storm, Discord Cloudflare hiccup, WhatsApp API blip) never permanently loses a deliverable message, while a genuinely poisoned payload still dead-letters within the day. Only lower it when you have a specific need — e.g. `dead_letter_min_age=0` inside a test suite where you want the pre-#3521 attempt-only behaviour, or `dead_letter_min_age=60` in a synthetic burn-in that can't wait 6 hours to observe the terminal state.
  </Accordion>

  <Accordion title="Import AttemptAndAgeDeadLetterPolicy from praisonaiagents.gateway">
    The `praisonai-bot` package's dependency floor (`praisonaiagents>=1.6.152`) admits core releases that predate the shared `AttemptAndAgeDeadLetterPolicy` (first shipped 1.6.161). On those installs the queues fall back to a bundled `LocalDeadLetterPolicy` with identical semantics, so the age gate holds regardless of which core version is installed. If you customise the policy, prefer importing `AttemptAndAgeDeadLetterPolicy` from `praisonaiagents.gateway` — the import will succeed on any core ≥ 1.6.161.
  </Accordion>
</AccordionGroup>

***

## Proactive Path

Direct `BotOS.deliver(...)` shares the reply-path rate limiter but is fire-and-forget. When the gateway itself performs the scheduled/proactive send, it runs through a durable outbox instead.

| Property                               | Reply-path outbox (`delivery.send`) | Direct `BotOS.deliver(...)`          | Gateway-hosted scheduled / proactive                                            |
| -------------------------------------- | ----------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------- |
| Persistence                            | SQLite outbox, survives restart     | Not persisted — fire-and-forget      | SQLite outbox at `~/.praisonai/state/gateway_outbox.sqlite`, survives restart   |
| Cross-worker dedup                     | Yes (`UNIQUE` on `idempotency_key`) | ❌ not supported                      | ✅ Yes (`UNIQUE` on the derived idem key)                                        |
| Idempotency key                        | ✅                                   | ❌ not a parameter                    | ✅ derived from `sched:<channel>:<channel_id>:<session_id>:<text_digest>`        |
| Rate limiter shared                    | ✅ same bucket                       | ✅ same bucket (post PraisonAI #2578) | ✅ same bucket                                                                   |
| Adapter `was_delivered` reconciliation | ✅                                   | ❌ not applicable                     | Available if the adapter opts in (same `OutboundQueue.drain(reconciler=…)` API) |

<Note>
  Use `delivery.send(...)` when you need durability across restarts, workers, or deduplication on the reply path. The gateway's own scheduled/proactive deliveries now get durability automatically — no extra API required. Reach for direct `BotOS.deliver(...)` (agent-initiated fire-and-forget, no gateway) only when rate-limiting is enough and durability across restart is not required.
</Note>

***

## Per-Adapter Delivery Guarantee

Only adapters that declare `reconciles_unknown_send=True` and implement `was_delivered` reach effectively-once; the rest stay at-least-once by design.

| Adapter   | `reconciles_unknown_send` | Delivery guarantee                           |
| --------- | :-----------------------: | -------------------------------------------- |
| Slack     |             ✅             | Effectively-once (via `metadata.event_type`) |
| AgentMail |             ❌             | At-least-once + provider-level dedup²        |
| Telegram  |             ❌             | At-least-once                                |
| Discord   |             ❌             | At-least-once                                |
| Webhook   |             ❌             | At-least-once                                |
| HTTP      |             ❌             | At-least-once                                |

² `AgentMailBot.send_message(..., idempotency_key=…)` forwards the outbox key as an AgentMail `Idempotency-Key` header (1–256 chars, 24h TTL). The outbox key survives crashes, so a re-send of the same outbox entry is deduplicated at the AgentMail provider — even without a `was_delivered` reconciler. See [Duplicate protection on retries](/docs/features/email-bot#duplicate-protection-on-retries).

Slack is the first built-in adapter to implement the reconciliation seam end-to-end:

* `SlackBot` declares `reconciles_unknown_send=True` on its `platform_capabilities`.
* `send_message(target, text, ..., idempotency_key=None)` stamps the key into the Slack message `metadata` with `event_type="praisonai_outbound"`.
* `was_delivered(target, idempotency_key)` reads back recent history (`conversations.history`, or `conversations.replies` for a threaded send) and matches the key in `metadata.event_payload`. It returns `False` on any lookup failure, falling back to at-least-once.

```
Before: sending → (restart) → recovered → re-dispatched blindly → duplicate
After:  sending → (restart) → recovered → reconciler confirms → marked sent (no duplicate)
```

<Note>
  Channels whose platform cannot confirm delivery remain at-least-once by design — that is now an explicit per-channel fact, not a silent default. See [Bot Platform Capabilities](/docs/features/bot-platform-capabilities).
</Note>

<Note>
  For those at-least-once channels you can opt into visible labelling of crash-recovered re-sends with `mark_recovered=True`. The recipient sees a `♻️ Recovered reply — the gateway restarted during delivery, so this may be a duplicate.` prefix instead of a silent possible-duplicate. See [Labelling the at-least-once fallback](/docs/docs/features/bot-platform-capabilities#labelling-the-at-least-once-fallback).
</Note>

***

## Related

<CardGroup cols={2}>
  <Card title="Outbound Ordering" icon="arrow-down-1-0" href="/docs/features/outbound-ordering">
    Per-conversation FIFO ordering on this outbox — keep messages to a chat in order under retries
  </Card>

  <Card title="Dead Target Registry" icon="shield-x" href="/docs/features/dead-target-registry">
    Suppress permanently-dead channels — the permanent-failure complement to durable retry
  </Card>

  <Card title="Inbound Journal" icon="book" href="/docs/features/inbound-journal">
    Inbound counterpart — deduplicate webhook redeliveries and recover in-flight messages
  </Card>

  <Card title="Inbound DLQ" icon="inbox" href="/docs/features/inbound-dlq">
    Dead-letter queue for failed inbound message processing
  </Card>

  <Card title="Delivery Config" icon="settings" href="/docs/features/delivery-config">
    Configure outbound resilience for all six bot channels
  </Card>

  <Card title="Bot Streaming Replies" icon="waveform" href="/docs/features/bot-streaming-replies">
    Live-edit streaming UX for bot responses
  </Card>

  <Card title="Messaging Bots" icon="message-circle" href="/docs/features/messaging-bots">
    Top-level guide to building bots with PraisonAI
  </Card>
</CardGroup>
