Skip to main content
from praisonaiagents import Agent

agent = Agent(name="gateway-agent", instructions="Route outbound bot messages safely.")
agent.start("Send the daily digest to the Telegram group.")
The dead-target registry stops your gateway from wasting requests on channels where the bot was kicked, the group was deleted, or a 403/404 is permanent — and automatically self-heals when a target recovers. The user triggers an outbound send; the registry skips dead targets and re-probes them when recovery is due.
The registry is default OFF. The DeliveryRouter works exactly as before until you construct and attach a DeadTargetRegistry instance.

Quick Start

1

Enable the Registry

Attach a registry to suppress known-dead targets:
from praisonaiagents import Agent
from praisonai.bots import TelegramBot
from praisonai.bots import DeadTargetRegistry, DeliveryRouter

agent = Agent(
    name="Broadcast Agent",
    instructions="Deliver scheduled updates to subscribers.",
)

registry = DeadTargetRegistry()

bot = TelegramBot(
    token="YOUR_TOKEN",
    agent=agent,
    dead_target_registry=registry,
)
await bot.start()
2

Persist to a Custom Path

By default the registry persists to ~/.praisonai/state/dead_targets.json. Override it:
from pathlib import Path
from praisonai.bots import DeadTargetRegistry

registry = DeadTargetRegistry(
    persist_path=Path("/var/lib/praisonai/dead_targets.json"),
    ttl_seconds=7 * 86400,      # forget after 7 days
    max_size=5000,               # cap at 5 000 dead entries
    reprobe_seconds=1800,        # re-probe every 30 minutes
)
3

Share Across Multiple Workers

For a horizontally-scaled gateway, pass a shared DeliveryControlStore so every worker sees the same dead-target set. Marking a channel dead in worker A immediately suppresses sends from worker B; a successful send from any worker self-heals across all of them.
from pathlib import Path
from praisonai.bots import DeadTargetRegistry, DeliveryControlStore

store = DeliveryControlStore(Path("~/.praisonai/state/delivery_control.sqlite"))

registry = DeadTargetRegistry(
    store=store,
    ttl_seconds=7 * 86400,
    max_size=5000,
    reprobe_seconds=1800,
)
Every registry sharing a store must use identical ttl_seconds and max_size. The dead-target sweep is table-wide (no per-registry column), so divergent bounds would silently prune the other registry’s suppressions. DeliveryControlStore.register_dead_config() enforces this at construction: a second registry attaching to the same store with different bounds raises ValueError with a clear message. Use separate store files for registries with different settings.
4

Inspect and Clear Manually

from praisonai.bots import DeadTargetRegistry

registry = DeadTargetRegistry()

dead = registry.list_dead()
for target in dead:
    print(f"{target.platform}:{target.channel_id}{target.reason}")

registry.clear("telegram", "-1001234567890")
print(f"Registry size: {registry.size()}")

How It Works

Self-heal timeline:
PhaseBehaviour
mark_deadTarget recorded with timestamp. Persisted atomically to JSON.
suppressedEvery deliver() call returns False immediately — no API hit.
reprobeAfter reprobe_seconds, one send is allowed through to test recovery.
self-healSuccess clears the registry entry. Next send goes through normally.
re-markedPermanent failure on reprobe resets the clock for another cycle.

Configuration Options

DeadTargetRegistry constructor

from praisonai.bots import DeadTargetRegistry

registry = DeadTargetRegistry(
    persist_path="~/.praisonai/state/dead_targets.json",
    max_size=10_000,
    ttl_seconds=2_592_000,
    reprobe_seconds=3600,
)
OptionTypeDefaultDescription
storeDeliveryControlStore | NoneNoneShared SQLite store for cross-worker sharing. When set, persist_path is ignored — dead-target state lives entirely in the store (row-based atomic upsert, no whole-file JSON rewrite). Every registry sharing one store must use identical ttl_seconds / max_size (enforced by register_dead_config, raises ValueError on mismatch). See Multi-worker (horizontally-scaled gateway).
persist_pathPath | None~/.praisonai/state/dead_targets.jsonJSON file for durability. Parent directories are created automatically. Pass a tmp path for ephemeral / test instances. Ignored when store= is set.
max_sizeint10_000Maximum dead entries kept. Oldest (by ts) are evicted when exceeded. <= 0 disables the bound.
ttl_secondsint2_592_000 (30 days)Entries older than this are dropped lazily on the next access, so a long-dead target is eventually re-probed even if no send ever cleared it. <= 0 disables TTL.
reprobe_secondsint3600 (1 hour)Once this elapses since mark_dead, one send is allowed through to test recovery. Success self-heals; repeated permanent failure resets the clock. <= 0 disables re-probing.

Public methods

MethodReturnsDescription
is_dead(platform, channel_id)boolTrue if currently suppressed. Expired entries are removed lazily and False is returned.
should_reprobe(platform, channel_id)boolTrue if a dead target is due for a single self-healing re-probe (reprobe_seconds elapsed since last mark_dead).
mark_dead(platform, channel_id, reason)NoneRecord a confirmed-permanent failure. Idempotent — re-marking refreshes the timestamp and resets the re-probe clock. Persisted atomically.
clear(platform, channel_id)NoneUn-suppress a target. Called automatically on every successful send. No-op if the target is not in the registry. Persisted atomically.
list_dead()list[DeadTarget]Snapshot of currently-suppressed targets (TTL-expired ones pruned first), sorted oldest-first by ts.
size()intNumber of currently-suppressed targets (TTL-expired ones pruned first).
DeadTarget is a frozen dataclass: platform: str, channel_id: str, reason: str, ts: float (Unix epoch).

What Counts as Permanent

is_permanent_target_failure(err, platform) classifies an exception before DeliveryRouter calls mark_dead.

Permanent — target marked dead

SignalDetails
HTTP 403 ForbiddenVia err.status, err.status_code, or err.error_code
HTTP 404 Not FoundSame attribute probe — only if not a message-scoped 404 (see exclusions below)
HTTP 410 GoneSame attribute probe
forbiddenText pattern (case-insensitive) on the exception message
bot was kicked
bot was blocked
user is deactivated
chat not found
channel not found
group chat was deleted
the group chat was deleted
bot is not a member
not enough rights
have no rights to send
need administrator rights
peer_id_invalid
account_not_found
channel_archived
is_archived
blocked by the user

Not permanent — stays on the retry path

SignalWhy excluded
Any transient / recoverable error5xx, 429, timeouts, connection resets — checked first, transient wins
message to edit not foundMessage-scoped 404 — does NOT condemn the whole channel
message not foundSame
message to delete not foundSame
message_id_invalidSame
thread not foundSame
unknown messageSame
reply not foundSame
message can't be editedSame
HTTP 401 UnauthorizedDeliberately excluded. 401 signals an account-/token-level auth problem (revoked or wrong credentials), not the death of one specific channel. Marking channels dead on a 401 would suppress every target the moment a token expires, and the 30-day TTL would keep them suppressed long after the token is fixed. Auth failures stay off the dead-target path.
Platform names are stored lower-cased. mark_dead("Telegram", ...) and is_dead("TELEGRAM", ...) refer to the same entry. Users coming from YAML keys like Telegram: don’t need to worry about casing.

Adapter Return Conventions

A channel adapter’s send_message() can signal delivery outcome three ways:
Return valueMeaningDead-target effect
None / no valueSuccess (dominant convention)Counter cleared on success
False (explicit)Transient failure — idempotency key NOT recorded, dead-target flag NOT tripped, safe to retryNo change
Raises exceptionFailure — exception flows to the failure path, dead-target counter increments if a permanent error is detectedCounter increments on permanent errors (403/404/410)
The False convention was formalised in PraisonAI #2578. Adapters that previously returned False silently to signal “send skipped” should now raise an exception or return None to avoid being misclassified as a transient failure.

Common Patterns

Long-running broadcast gateway

A gateway pushing periodic updates to many channels suppresses dead ones so they don’t burn rate-limit budget on every cycle.
import asyncio
from praisonai.bots import BotOS, DeliveryRouter, DeadTargetRegistry

registry = DeadTargetRegistry()
botos    = BotOS(...)
router   = DeliveryRouter(botos, dead_targets=registry)

async def broadcast(channels, message):
    for platform, channel_id in channels:
        await router.deliver(f"{platform}:{channel_id}", message)
Dead channels are skipped silently; recovered channels re-enter service within one reprobe cycle (~1 h by default).
On a multi-worker gateway, pass the same DeliveryControlStore to every worker’s registry and rate limiter. That way, a channel marked dead by worker A immediately stops burning fan-out cycles on workers B/C/D. See Multi-worker (horizontally-scaled gateway).

Operator dashboard via list_dead()

Expose suppressed channels in a health or admin endpoint so operators can see what has been suppressed and why.
from praisonai.bots import DeadTargetRegistry

registry = DeadTargetRegistry()

@app.get("/admin/dead-targets")
async def dead_targets():
    return [
        {"platform": t.platform, "channel_id": t.channel_id,
         "reason": t.reason, "since": t.ts}
        for t in registry.list_dead()
    ]

Custom suppression for one chat

Un-suppress a channel manually after confirming the bot has been re-added.
registry.clear("telegram", "-1001234567890")

Best Practices

Attach a registry only on long-running gateways with periodic, broadcast, or scheduled sends. Single-request CLIs and request-response bots don’t need it — adding suppression to short-lived processes just adds complexity with no benefit.
When several workers share one DeliveryControlStore, they must agree on ttl_seconds and max_size. DeliveryControlStore.register_dead_config() enforces this at attach time — the second worker with divergent bounds raises ValueError instead of silently pruning the first worker’s suppressions. Roll config changes through the whole fleet together; if you genuinely need two different retention windows, use two separate store files.
Never treat HTTP 401 Unauthorized as a dead-target signal. Token expiry would suppress every channel simultaneously, and the 30-day TTL would hide the fix for a month. Auth failures stay on the normal error path so the operator sees and fixes them quickly.
Store the registry on a persistent local path — not /tmp and not a container tmpfs. The default ~/.praisonai/state/dead_targets.json is the right location; use it as a template.
registry = DeadTargetRegistry(
    persist_path="/data/praisonai/state/dead_targets.json"
)
The default 1-hour reprobe window suits most periodic-update gateways. Raise it if kicked-bot scenarios are rare and you’d rather not probe recovered channels frequently. Lower it if recovery latency matters (e.g. high-priority alert channels).
registry = DeadTargetRegistry(reprobe_seconds=300)
Expose list_dead() in your gateway’s admin or health endpoint so operators can see suppressed channels at a glance. Pair it with registry.size() for a quick health metric.
print(f"Suppressed channels: {registry.size()}")
for t in registry.list_dead():
    print(f"  {t.platform}:{t.channel_id}{t.reason}")
The registry is most valuable when you push messages to many stored targets (newsletters, alerts). For one-on-one chat bots, the existing retry path is sufficient.
The JSON file survives restarts and preserves which channels are suppressed between runs. Store it on the same persistent volume as your SQLite approval store.
When a successful send reaches a channel that was in the dead list — even a regular delivery, not only a scheduled re-probe — the channel is cleared automatically. No manual intervention needed.
The 30-day default suits most bots. On platforms where users frequently delete and recreate accounts, use a shorter TTL (e.g. 1–7 days) to allow re-delivery sooner.
Each entry uses ~100 bytes. max_size=10_000 uses ~1 MB. For bots with millions of users, increase max_size proportionally or use a persistent backend.
If an operator manually restores a blocked channel, call registry.clear(platform, channel_id) so the next send goes through. Without clearing, the entry persists until TTL expiry.
The registry prevents re-sending to dead targets, but in-flight messages that fail permanently should also go to the outbound DLQ. See Durable Delivery and Delivery Config.
If users typically re-add your bot within 7 days, set ttl_seconds=604800. The registry will forget the entry and perform a clean probe attempt rather than waiting the full 30-day default.
A rapidly-growing registry is a signal that your bot is being kicked at scale. Investigate the root cause before the registry fills to max_size and starts evicting entries.
The registry writes atomically to a JSON file. On failure the write is logged as a warning but never raised — the in-memory state is still correct and the file will be updated on the next successful write.

Durable Delivery

Persistent outbox + retry for transient failures — composes with the dead-target registry

Channel Supervision

Self-healing gateway channels with operator pause/resume/reconnect controls

Proactive Delivery

Scheduled and broadcast outbound message delivery

Delivery Config

Delivery surface configuration reference