Scheduler Delivery

Push scheduled agent results to Telegram, Discord, Slack, WhatsApp, or Signal with one param

Set deliver= on a scheduled agent and each result is pushed to a chat target — no gateway required.

python
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler

agent = Agent(name="Brief", instructions="Summarise the morning news")
AgentScheduler(agent, task="Morning brief", deliver="telegram:123456").start("hourly")
Diagram

Scheduler Delivery

⏰ Scheduled Agent

📤 deliver token

🔀 DeliveryRouter

✅ Telegram / Discord / Slack / WhatsApp / Signal

“Run this agent every hour and text me the result on Telegram.” That used to mean wiring up the scheduler and the full BotOS gateway. Now it’s one parameter — the scheduler talks to the shared DeliveryRouter directly.

Quick Start

  1. Deliver to a specific chat
    python
    from praisonaiagents import Agent
    from praisonai.scheduler import AgentScheduler
    
    agent = Agent(name="Brief", instructions="Summarise the morning news")
    
    scheduler = AgentScheduler(agent, task="Morning brief", deliver="telegram:123456")
    scheduler.start("hourly")
  2. Deliver to the platform home channel
    python
    from praisonaiagents import Agent
    from praisonai.scheduler import AgentScheduler
    
    agent = Agent(name="Brief", instructions="Summarise the morning news")
    
    # Bare platform token → router resolves the platform's home channel
    scheduler = AgentScheduler(agent, task="Morning brief", deliver="telegram")
    scheduler.start("daily")
  3. Reply back to the origin chat
    python
    from praisonaiagents import Agent
    from praisonai.scheduler import AgentScheduler
    
    agent = Agent(name="Brief", instructions="Summarise the morning news")
    
    # Inside a bot handler: the incoming request's origin is persisted on the job
    # config, so deliver="origin" resolves it on the lightweight path.
    scheduler = AgentScheduler(
        agent,
        task="Daily brief",
        deliver="origin",
        config={"origin": incoming_origin},  # persisted ScheduleJob.origin (DeliveryTarget or dict)
    )
    scheduler.start("daily")
    # Fires every day and delivers back to the same channel/thread the user
    # scheduled it from — no gateway required.
  4. Async scheduler
    python
    import asyncio
    from praisonaiagents import Agent
    from praisonai.scheduler import AsyncAgentScheduler
    
    async def main():
        agent = Agent(name="Brief", instructions="Summarise the morning news")
        scheduler = AsyncAgentScheduler(agent, task="Morning brief", deliver="telegram:123456")
        await scheduler.start("hourly")
    
    asyncio.run(main())

Three Ways to Set the Target

The same delivery token works from Python, YAML, and the CLI.

Python

python
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler

agent = Agent(name="Brief", instructions="Summarise the morning news")
AgentScheduler(agent, task="Morning brief", deliver="telegram:123456").start("hourly")

YAML

yaml
# agents.yaml
framework: praisonai

agents:
  - name: "Brief"
    instructions: "Summarise the morning news"

task: "Morning brief"

schedule:
  every: "hourly"          # alias for `interval` — new in PR #2934
  deliver: "telegram:123456"

CLI

bash
praisonai schedule add "morning-brief" \
  -s hourly \
  -m "Morning brief" \
  --deliver telegram:123456

Tools in scheduled YAML

The tools: list inside agents.yaml resolves through the same ToolResolver that praisonai run agents.yaml uses — so any tool name the CLI accepts also works when the file is scheduled via AgentScheduler.from_yaml() or AsyncAgentScheduler.from_yaml(). See Tool Resolver.

yaml
# agents.yaml
framework: praisonai

agents:
  - name: "News Monitor"
    role: "News Analyst"
    goal: "Track AI news"
    instructions: "Search and summarise the last 24h."
    tools:
      - duckduckgo          # any tool praisonai run accepts, not just search_tool
      - internet_search

task: "Search for latest AI news"

schedule:
  every: "hourly"
  deliver: "telegram:12345"

Which surface fits which scenario?

Diagram

In your Python app

Config file, no code

One-off from terminal

How do you run it?

Python: AgentScheduler deliver=

YAML: schedule.deliver

CLI: –deliver / -d


Delivery Target Tokens

The deliver value is parsed by DeliveryTarget.parse() — the same serialisable model reused from the existing delivery machinery.

platform                          # bare platform, uses configured default channel
platform:channel_id               # concrete channel
platform:channel_id:thread_id     # threaded delivery (Slack thread ts,
                                  #   Telegram forum topic id, Discord thread id)
origin                            # persisted job origin (resolves on the lightweight path)
all                               # fan out to every bot (gateway only)
TokenMeaning
"telegram"Default (home) channel of that platform
"telegram:123456"Specific channel / chat ID
"telegram:123456:789"Channel 123456, delivered into thread 789
"origin"Reply to the same channel/thread the schedule was created from
"all"Fan out to every configured bot (gateway only)

Thread semantics per platform

The third :thread_id segment threads the outbound message. DeliveryRouter.resolve() returns (platform, channel_id, thread_id) and deliver() passes thread_id into bot.send_message(...). Adapters without a thread_id kwarg are unaffected — a guard introspects the adapter first.

Platformthread_id semantics
SlackParent message ts (thread timestamp)
TelegramForum topic message_thread_id
DiscordThread channel ID
WhatsAppIgnored (no thread concept)
OthersSilently ignored — adapters without a thread_id kwarg are unaffected
python
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler

agent = Agent(name="Notify", instructions="Post the hourly status")
AgentScheduler(agent, task="Notify", deliver="slack:C0123456:1728987654.001234").start("hourly")
# Delivers into the Slack thread rooted at message ts 1728987654.001234.

Swap telegram for discord, slack, whatsapp, or signal — the grammar is identical.

Diagram

A fixed chat / channel

Same chat the schedule was created from

Platform’s default/home channel

Every configured bot

Where should the result go?

Fixed target or same chat as creation?

deliver=’telegram:123456′

deliver=’origin’

deliver=’telegram’

deliver=’all’
requires BotOS gateway


Deliver back to the origin channel

Use deliver="origin" when the schedule was created from a chat and you want the recurring result to land back in that same chat — without wiring up a specific platform:channel_id token.

python
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler

agent = Agent(name="Brief", instructions="Summarise the morning news")

# Job was created from a Telegram chat — its origin is persisted on
# ScheduleJob.origin at creation. deliver="origin" delivers back to it.
scheduler = AgentScheduler(agent, task="Morning brief", deliver="origin")
scheduler.start("hourly")

How origin resolution works:

Diagram
Origin channelDeliveryRouterSchedulerDeliveryAgentSchedulerScheduleJob (persisted)Origin channelDeliveryRouterSchedulerDeliveryAgentSchedulerScheduleJob (persisted)_resolve_origin_target rewrites“origin” → “telegram:123456”config.origin = DeliveryTarget(channel=”telegram”, channel_id=”123456″)SchedulerDelivery(deliver=”origin”, origin=…)deliver(idempotency_key, target, payload)bot.send_message(…)ok

SchedulerDelivery.origin_from_config() normalises the origin whether it is a live DeliveryTarget or its persisted dict form (from to_dict()), so scheduled jobs restored from disk resolve correctly too.


Out-of-process delivery

Scheduled delivery works out of process — a plain OS cron entry, a CI runner, or a scale-to-zero deployment — using only {PLATFORM}_BOT_TOKEN. No persistent gateway, no adapter, no live process.

python
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler

agent = Agent(name="Brief", instructions="Summarise the morning news")

# No gateway running. TELEGRAM_BOT_TOKEN set in env. Called from OS cron:
#   0 * * * *  TELEGRAM_BOT_TOKEN=... praisonai schedule tick
AgentScheduler(agent, task="Morning brief", deliver="telegram:123456").start("hourly")

The line a user puts in crontab -e:

bash
# Every hour, out of process, no gateway
0 * * * *  TELEGRAM_BOT_TOKEN=xxxx praisonai schedule tick

Before this, running praisonai schedule tick out of process silently dropped delivery — the executor computed the result but its delivery_handler was None, so nothing was pushed. Now the executor falls back to a stateless, token-authenticated standalone sender when no live handler is wired. The send is no longer fire-and-forget: each HTTPS call is retried in-process on transient failures — see Bounded in-process retry.

Diagram

Out-of-process scheduled delivery

yes

no

yes

no

retry ×4 on transient

⏰ OS cron / CI

praisonai schedule tick

ScheduledAgentExecutor

live delivery_handler?

gateway send_message

Bundled platform?

🌐 standalone sender
Telegram · Slack · Discord · WhatsApp · Signal

📇 descriptor.standalone_sender

✅ chat

  1. Set the platform token
    bash
    # Same env the live gateway uses — read at send time, not import.
    export TELEGRAM_BOT_TOKEN=123456:AAE...
  2. Run one tick from cron / CI / serverless
    bash
    # One-shot from a plain OS-cron entry — no gateway, no bots running.
    praisonai schedule tick
    # → each due job runs, and any deliver: telegram target is POSTed to
    #   sendMessage using the token above.

The HTTP call uses the standard-library urllib.request, so there is nothing to install beyond praisonai-bot. Each send has a 30-second timeout, and a long result is auto-chunked into multiple messages so it is never rejected by the platform’s size limit.

Environment variables

Each platform is gated by its bot token. A bare deliver: telegram target (no explicit chat id) resolves its channel from a home-channel env var or the gateway’s persisted state file.

PlatformToken envHome-channel envPlatform API endpointSize limitThread field
telegramTELEGRAM_BOT_TOKENTELEGRAM_HOME_CHANNELPOST https://api.telegram.org/bot{token}/sendMessage4096 charsmessage_thread_id
slackSLACK_BOT_TOKENSLACK_HOME_CHANNELPOST https://slack.com/api/chat.postMessage (Bearer auth)39000 charsthread_ts
discordDISCORD_BOT_TOKENDISCORD_HOME_CHANNELPOST https://discord.com/api/v10/channels/{id}/messages (Bot auth)2000 charsmessage_reference (fail_if_not_exists=false)
whatsappWHATSAPP_ACCESS_TOKEN + WHATSAPP_PHONE_NUMBER_IDWHATSAPP_HOME_CHANNELPOST https://graph.facebook.com/v20.0/{phone_number_id}/messages (Bearer auth)4096 chars(ignored — see per-platform note)
signalSIGNAL_ACCOUNT, SIGNAL_BRIDGE_URL (default http://localhost:8080)SIGNAL_HOME_CHANNELPOST {SIGNAL_BRIDGE_URL}/v2/send2000 chars(n/a)

Long results are split with the same markdown-aware chunk_message helper the live adapters use, so a result over the size limit is delivered as multiple messages instead of being rejected. On Discord, a target that carries a thread_id is preserved via message_reference (with fail_if_not_exists=false) so the message stays in the same conversation.

Chat-id resolution order

_resolve_chat_id walks three sources in order — the first non-empty value wins.

Diagram

Yes

No

Yes

No

Yes

No

deliver target

Explicit channel_id?
e.g. telegram:123456

Use it

PLATFORM_HOME_CHANNEL env set?

Use env value

home_channels.json has entry?

Use persisted chat id

⚠️ no chat id → delivery_error

OrderSourceWhy
1Explicit channel_id on the deliver: target (telegram:123456)Most specific — always wins.
2{PLATFORM}_HOME_CHANNEL env varEnv-first, so a deployment can override without touching gateway state.
3~/.praisonai/state/home_channels.jsonThe gateway’s persisted HomeChannelRegistry. Populate it via {PLATFORM}_HOME_CHANNEL, a startup call to HomeChannelRegistry().set_home(...), or a YAML deliver: target.

Failure modes

Nothing is hidden — a target that cannot be delivered is recorded as delivery_error on the run instead of being silently dropped.

ScenarioResult
Unroutable token at schedule creation (missing/malformed channel_id, unknown platform, empty target)SchedulerDelivery logs an actionable warning with reason + hint; construction still returns so callers that only want a preview aren’t blocked. A caller that wants hard-fail behaviour can raise ScheduleTargetError explicitly. See Creation-time validation and preview.
Token env unsetRun succeeds; delivery_error recorded ("{TOKEN} not set for standalone delivery").
Bare-platform target and no {PLATFORM}_HOME_CHANNEL + no registry entryRun succeeds; delivery_error recorded.
Unsupported platform with no standalone_sender on its descriptorRun succeeds; delivery_error recorded.
Custom / entry-point platform whose descriptor does declare standalone_senderDelivered via the plugin’s sender, with the same semantics as the bundled senders: raise → delivery_error, return False → delivery_error, otherwise delivered=True.
Slack {"ok": false} bodyRun succeeds; delivery_error records the Slack error name
Payload > platform limitAuto-chunked into multiple messages (see per-platform table below)
Live delivery_handler presentLive path wins; standalone sender is never invoked
Gateway routing fails (router present, but the target platform has no live bot / delivery-target unresolved)Run succeeds; delivered=False, delivery_error records "delivery to <channel>:<id> did not complete". (Fixed in PraisonAI #4198 — previously recorded silently as delivered=True.)
Delivery target missing channel or channel_idRun succeeds; delivered=False, delivery_error records the incomplete-delivery message.
No live router and no registered channel bot for the target platformRun succeeds; delivered=False, delivery_error records the incomplete-delivery message.
deliver_on_failure=True and the failure summary reached the channelRun recorded failed (execution error), delivered=True, delivery_error=None. The failure summary is a payload, and it arrived.
deliver_on_failure=True and the failure summary did not arriveRun recorded failed, delivered=False, delivery_error="failure-summary delivery to <channel>:<id> did not complete" (or the raised exception message if the send raised).

Per-platform detail

PlatformToken envHome-channel envChunk limitThread mapping
TelegramTELEGRAM_BOT_TOKENTELEGRAM_HOME_CHANNEL4096 charsthread_id → message_thread_id (forum topic)
SlackSLACK_BOT_TOKENSLACK_HOME_CHANNEL39000 charsthread_id → thread_ts (parent message ts)
DiscordDISCORD_BOT_TOKENDISCORD_HOME_CHANNEL2000 charsthread_id → message_reference reply
WhatsAppWHATSAPP_ACCESS_TOKEN + WHATSAPP_PHONE_NUMBER_IDWHATSAPP_HOME_CHANNEL4096 charsIgnored — WhatsApp has no thread concept; a scheduler thread_id is not a WhatsApp context.message_id and is deliberately dropped (see the WhatsApp note below)
SignalSIGNAL_ACCOUNT (sender number)SIGNAL_HOME_CHANNEL (recipient)2000 charsNot applicable (Signal has no threads)
Custom / entry-point platforms (praisonai.channels)Set by the pluginSet by the pluginSet by the pluginSupported when the plugin’s ChannelDescriptor declares standalone_sender. See Standalone Sender for Custom Channels. Without one, delivery raises → delivery_error

Signal also reads SIGNAL_BRIDGE_URL (defaults to http://localhost:8080, matching the live Signal Bot adapter) for the signal-cli-rest-api bridge endpoint.

Telegram

Set TELEGRAM_BOT_TOKEN and either use an explicit deliver: telegram:<chat_id> target or set TELEGRAM_HOME_CHANNEL. thread_id on the target maps to message_thread_id (forum topic).

Slack

Set SLACK_BOT_TOKEN and either use deliver: slack:<channel_id> or set SLACK_HOME_CHANNEL. thread_id maps to thread_ts. Slack returns HTTP 200 even for logical failures like a bad channel — those surface as delivery_error in the run record.

Discord

Set DISCORD_BOT_TOKEN and either use deliver: discord:<channel_id> or set DISCORD_HOME_CHANNEL. thread_id becomes a message_reference reply to preserve conversation context; if the referenced message is gone, delivery degrades gracefully to a normal message instead of dropping.

WhatsApp

Set WHATSAPP_ACCESS_TOKEN and WHATSAPP_PHONE_NUMBER_ID (the same env the live adapter reads). Target with deliver: whatsapp:<recipient_number> or set WHATSAPP_HOME_CHANNEL. The payload is a plain-text WhatsApp Cloud API message POSTed to https://graph.facebook.com/v20.0/{phone_number_id}/messages.

Signal

End-to-end encrypted via a signal-cli-rest-api bridge (the same one the live Signal Bot uses). Set SIGNAL_ACCOUNT (your linked sender number, e.g. +15550000) and optionally SIGNAL_BRIDGE_URL (defaults to http://localhost:8080). Target with deliver: signal:<recipient_number_or_group> or set SIGNAL_HOME_CHANNEL. The bridge must be running and reachable from wherever praisonai schedule tick fires — see the Signal Bot page for the Docker one-liner.

Bounded in-process retry

An ephemeral tick (cron / CI / serverless) has no persistent process to drain a durable outbox, so retry here is a bounded in-process retry — dedup, by contrast, stays durable via the shared delivery.db (see Restart-safe delivery). Each HTTPS send is attempted up to 4 times with exponential backoff (1 s → 2 s → 4 s → 8 s, capped at 20 s, ±20 % jitter). A server-mandated Retry-After — integer-seconds or HTTP-date — is honoured over the computed backoff. Only transient failures (5xx, 429, network) are retried; a permanent failure (bad token, 4xx) raises on the first attempt so the executor records delivery_error immediately rather than burning the tick on a doomed send.

FieldValueSource
Max attempts (_MAX_ATTEMPTS)4_standalone_sender.py
Initial backoff1000 msBackoffPolicy(initial_ms=1000, ...)
Max backoff20000 msBackoffPolicy(max_ms=20000, ...)
Backoff factor2.0BackoffPolicy(factor=2.0, ...)
Jitter0.2 (±20 %)BackoffPolicy(jitter=0.2)
Retry-AfterServer value wins when presentserver_retry_after(exc)
ClassifierShared with interactive repliesis_recoverable_error(exc)

The raw Retry-After header is preserved verbatim so bots._resilience.server_retry_after can parse both integer-seconds and HTTP-date forms — a plain float(header) would silently drop an HTTP-date delay under 429 throttling.

Dedup for the ephemeral tick is not best-effort: the same durable delivery.db is used, so an OS-cron praisonai schedule tick re-run after a crash is deduplicated exactly like a long-running scheduler. Only the retry side stays best-effort here (no persistent process to drain a durable outbox) — see Restart-safe delivery.

Diagram

No — 4xx, bad token

Yes

No

Yes

Yes

No

❌ Send failed

Transient?
5xx / 429 / network

🟥 Raise → delivery_error

Attempts < 4?

Server Retry-After?

⏳ Sleep Retry-After

⏳ Sleep backoff
1s → 2s → 4s → 8s ±20%

🟩 Retry send

End-to-end: cron deployment walkthrough

  1. Register your home channel once

    Set the platform’s home-channel env var before starting the gateway (or the cron job):

    bash
    export TELEGRAM_HOME_CHANNEL=123456789

    Or, from a Python startup script:

    python
    from praisonai_bot.gateway import HomeChannelRegistry
    
    HomeChannelRegistry().set_home("telegram", "123456789")

    Either path records your chat id to ~/.praisonai/state/home_channels.json, which praisonai schedule tick reads from cron with no gateway.

  2. Create a job with a bare platform target
    bash
    praisonai schedule add "morning-brief" -s hourly -m "Morning brief" --deliver telegram

    No explicit chat id — it resolves from the persisted registry.

  3. Schedule the tick in OS cron
    bash
    # crontab -e
    0 * * * *  TELEGRAM_BOT_TOKEN=xxxx praisonai schedule tick
  4. Gateway off — cron fires — message lands

    The gateway is asleep. Cron fires, the job runs, and the standalone sender resolves the chat id from the persisted registry file. The brief arrives in your chat.


Restart-safe delivery

A scheduled brief is delivered exactly once even across a crash or restart — no gateway required.

python
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler

agent = Agent(name="Brief", instructions="Summarise the morning news")

# Same one-liner — dedup is durable by default.
AgentScheduler(agent, task="Morning brief", deliver="telegram:123456").start("hourly")
Diagram
Telegram / Slack / …delivery.dbDeliveryRouterScheduled JobTelegram / Slack / …delivery.dbDeliveryRouterScheduled Jobalt[fresh key][already recorded / inflight]On failed / cancelled send: release(key) so the retry is notdeduplicated against its own crashed attempt.deliver(key, target, payload)reserve(key)claimed (inflight)send_message(…)okrecord(key)duplicateskip (no double-post)

The dedup ledger lives at ~/.praisonai/state/delivery.db (SQLite, stdlib). Set PRAISONAI_HOME=/my/path to relocate it — two isolated deployments under one OS account then keep separate ledgers instead of cross-suppressing each other’s delivery keys. Gateway, lightweight scheduler, and out-of-process tick under one home all converge on the same file, so the guarantee is path-independent.

A failed send releases its reservation, so the next tick can re-claim the key — a retry after a transient error is not silently deduplicated. asyncio.CancelledError (raised when a supervisor or systemd stops a send mid-flight) also releases the reservation before propagating, so a cancelled send stays retryable rather than being deduped against its own aborted attempt.

If the durable store cannot be built (missing praisonai-bot extra, unwritable state dir), delivery falls back to the router’s in-process LRU rather than blocking — durability degrades, delivery never does. Every failure path (store unavailable, reserve/record/release raising) logs at debug and falls back — delivery is never blocked by the dedup infrastructure.


Continuable Delivery

A delivered brief is a conversation opener, not a dead-end. Reply in the same chat and the agent picks up where the brief left off — with the delivered text already in context. Zero configuration, on by default.

Diagram
🧵 Bot SessionUser💬 Telegram / Slack / Discord🔀 DeliveryRouter⏰ Scheduled Job🧵 Bot SessionUser💬 Telegram / Slack / Discord🔀 DeliveryRouter⏰ Scheduled Jobseed continuable sessionmirrors brief into sessionkeyed by chat idsession already carriesthe brief — reply resumesthe conversationdeliver(text)send_message(brief)ok“dig into item 3”reply (same chat id)contextual answer

Before / after #3449:

ScenarioBefore #3449After #3449 (default)
User replies to a delivered daily-brief with “why did revenue dip on Tuesday?”Reply lands as a new turn — agent has no idea which report this refers to.Reply resumes the job’s session — agent sees the brief it just delivered and answers in context.
Config requiredManually pre-wire session_id on DeliveryTarget.None — continuable=True is the default.
Fire-and-forget notice (e.g. “backup finished”)Same as above.Set continuable=False (or --no-continuable) to opt out.

Quick start — nothing to do

Continuable delivery is default-on. Every scheduled job with a delivery target already resumes its conversation on reply:

python
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler

agent = Agent(name="Brief", instructions="Summarise the morning news")
AgentScheduler(agent, task="Morning brief", deliver="telegram:123456").start("hourly")

# User replies "expand on item 2" in chat 123456 → resumes the job's conversation
# with the brief already in context. No wiring, no session id, no re-briefing.

Opt out for fire-and-forget alerts

Some deliveries are pure notifications (“build finished”, “backup ok”) — a reply should stay a fresh turn, not resume a maintenance job. Turn seeding off with a single flag:

CLI

bash
praisonai schedule add "backup-alert" \
  -s "cron:0 3 * * *" \
  -m "Backup ok" \
  --deliver "slack:C012345" \
  --no-continuable

Tool

python
from praisonaiagents.tools import schedule_add

schedule_add(
    name="backup-alert",
    schedule="cron:0 3 * * *",
    message="Backup ok",
    deliver="slack:C012345",
    continuable=False,      # fire-and-forget notification
)

Python

python
from praisonaiagents.scheduler.models import DeliveryTarget, ScheduleJob, Schedule

job = ScheduleJob(
    name="backup-alert",
    schedule=Schedule(kind="cron", cron_expr="0 3 * * *"),
    message="Backup ok",
    delivery=DeliveryTarget(
        channel="slack",
        channel_id="C012345",
        continuable=False,   # opt out — reply lands as a fresh turn
    ),
)

The seed contract

Given a successful delivery of text to channel:channel_id, the gateway seeds a resumable session:

PreconditionWhat the gateway does
continuable is True (default), agent-run resultMirrors text into the destination bot’s session under the delivery chat id as an assistant turn (authored_by="agent"). The user’s next message in this chat resumes the conversation.
continuable is True (default), command / backend output or a failure / hold / recovery noticeMirrors text as a user turn prefixed [cron] (authored_by="external"). The agent never thinks it wrote command output or a system notice.
continuable is FalseSkips seeding entirely. Reply lands as a brand-new, contextless turn.
Delivery failed (any path)Does not seed. Nothing was delivered, so nothing is claimed to have been.
Seeding raises (session missing, adapter oddity)Logs a single debug line and returns. The delivery has already succeeded and remains committed — a seeding failure never breaks it.
Intentional-silence run (NO_REPLY / [SILENT] / SILENT)Delivery is skipped — no delivery, no seed. See Intentional Silence.
python
# command job stdout, mirrored into the session
history = [
    {"role": "user",      "content": "..."},
    {"role": "assistant", "content": "...previous agent reply..."},
    {"role": "user",      "content": "[cron] Backup finished: 3 files", "mirror": True},
]

Configuration options

OptionWhereTypeDefaultDescription
continuableDeliveryTarget(continuable=…)boolTrueWhen True, seed the destination bot’s session with the delivered brief so a reply resumes the job’s conversation. Set False for pure fire-and-forget notifications.
continuableschedule_add(…, continuable=…)boolTrueThreaded through to the constructed DeliveryTarget.
--continuable / --no-continuablepraisonai schedule addflag--continuableApplied only when a delivery target is configured; a no-op on jobs with no delivery.

Serialisation. DeliveryTarget.to_dict() persists continuable only when it is False — the default is implied by absence, so existing on-disk schedule stores are byte-for-byte unchanged. from_dict() treats a missing field as True, so restored legacy jobs immediately gain the new behaviour on next fire.

When to opt out

Pure status alerts

Backup finished, deploy succeeded, monitoring “ok” heartbeat. There is no conversation to have — the reply, if any, is an unrelated command. Use --no-continuable so the chat context stays fresh.

Fan-out to shared channels (deliver=&quot;all&quot;)

A broadcast to every home channel implies the recipients aren’t a single continuing conversation. Seeding runs once per destination channel:channel_id, so a shared/group chat resumes the last fire’s context, not per-participant. If replies in the shared chat are noise, prefer --no-continuable.

Sub-minute ticks

A tight loop that seeds on every fire would overwrite the session before the user can read it. Prefer --no-continuable (or a coarser schedule) for high-frequency jobs.

Recurring briefs and digests (leave it on)

Recurring briefs, digests, standing reports, sensor summaries — the reader almost always wants to reply to the thing they just read. Leave continuable on.


How It Works

The scheduler runs the agent, then hands the result to SchedulerDelivery, which resolves the token and sends through the shared DeliveryRouter.

Diagram
ChannelDeliveryRouterSchedulerDeliveryAgentSchedulerChannelDeliveryRouterSchedulerDeliveryAgentSchedulerrewrite via _resolve_origin_targetusing ScheduleJob.originalt[deliver == “origin”][explicit target]gateway seeds a continuable session(skipped when continuable is False)run taskresultsend(result, deliver)deliver(key, resolved_target, payload)deliver(key, target, payload)bot.send_message(…)ok

Under the full gateway a live delivery_handler is wired. Run praisonai schedule tick out of process (cron / CI / serverless) and there is no live handler — the executor falls back to a per-platform standalone sender instead. See Out-of-process delivery.

Diagram
ChannelDeliveryRouter (gateway)Standalone senderScheduledAgentExecutorAgentSchedulerChannelDeliveryRouter (gateway)Standalone senderScheduledAgentExecutorAgentScheduleralt[live delivery_handler wired][no live handler]run taskresultdeliver(target, text)bot.send_message(…)resolve_standalone_sender(target.channel)token-authed HTTPS POSTok (or delivery_error on failure)

SchedulerDelivery is built once per scheduler and reused across runs, so the router’s rate limiters persist between ticks. Idempotency now persists further — across process restarts — because the router is injected with a shared durable SqliteIdempotencyStore at ~/.praisonai/state/delivery.db, not just an in-process cache. See Restart-safe delivery. After a successful delivery, a gateway-hosted job also seeds a resumable session so a reply resumes the conversation — see Continuable Delivery.


Intentional Silence

A scheduled run whose entire output is exactly NO_REPLY, [SILENT], or SILENT skips delivery — nothing is pushed to the chat target. The run is still recorded as succeeded in scheduler history and metrics.

python
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler

agent = Agent(
    name="Watchdog",
    instructions=(
        "Every hour, check the log file for new ERROR lines. "
        "If there are none, reply with exactly: NO_REPLY. "
        "Otherwise summarise the errors."
    ),
)
AgentScheduler(agent, task="Hourly check", deliver="telegram:123456").start("hourly")

The noisy NO_REPLY control token is never posted to the channel — the tick just doesn’t produce any message.

Diagram
Chat channelDeliveryAgentSchedulerChat channelDeliveryAgentScheduleris_intentional_silence_response(“NO_REPLY”) → Trueno message postedrun(task)“NO_REPLY”delivery suppressedrecord run as succeeded
Agent outputDelivery
NO_REPLY (exact, case-insensitive, whitespace-trimmed)Suppressed
[SILENT] / SILENTSuppressed
I think NO_REPLY is a good idea (prose)Delivered normally
Any other textDelivered normally

The marker check calls is_intentional_silence_response from praisonaiagents.bots.silence — the same primitive the chat-bot path uses. All three unattended paths honour it via the shared _BaseAgentScheduler._should_suppress_delivery helper: AgentScheduler._deliver_result (sync lightweight), AsyncAgentScheduler._deliver_result (async lightweight, wired in PraisonAI #3420), and the full-gateway ScheduledAgentExecutor.

PathSilence marker suppressed
AgentScheduler (sync lightweight)✅ Yes
AsyncAgentScheduler (async lightweight)✅ Yes (fixed in #3420)
ScheduledAgentExecutor (full gateway)✅ Yes

Reading the outcome from stats {#delivery-outcomes}

Every scheduled run resolves to one of four typed outcomes — a failed delivery is no longer reported as a silent success (PraisonAI PR #4476, issue #4454). Each outcome bumps its own counter — delivered_deliveries or undelivered_deliveries — read from scheduler.get_stats().

Diagram

No

Yes

Yes

No

Yes

No

⏰ Scheduled run

deliver= set?

NOT_CONFIGURED

Whole output is
NO_REPLY / SILENT?

SUPPRESSED

Router accepted?

DELIVERED

UNDELIVERED

✅ on_success

🟥 on_failure

delivered_deliveries and undelivered_deliveries are explicit counters — a run only bumps the counter for its own outcome, never inferred by subtracting one from the other. SUPPRESSED (intentional silence) and NOT_CONFIGURED (no deliver= target) runs contribute zero to both, so an alert like delivered_deliveries + undelivered_deliveries < successful_executions is a real signal of quiet or unconfigured runs, not a bug.

The DeliveryOutcome enum is importable from praisonai.scheduler._base_scheduler and has a .undelivered convenience property that returns True only for UNDELIVERED.

Per-outcome counter table

Run outcomedelivered_deliveriesundelivered_deliveriesCallback fired
DELIVERED (router accepted)+10on_success(result)
UNDELIVERED (router returned False)0+1on_failure("scheduled result could not be delivered")
UNDELIVERED (delivery raised)0+1on_failure("scheduled result could not be delivered")
SUPPRESSED (NO_REPLY / [SILENT] / SILENT)00on_success(result)
NOT_CONFIGURED (no deliver= target)00on_success(result)

The invariant holds on both the sync and async scheduler:

delivered_deliveries + undelivered_deliveries ≤ successful_executions

The gap is exactly the SUPPRESSED + NOT_CONFIGURED runs — successful executions that were never meant to reach a chat target.

Quick Start

on_success and on_failure now split cleanly — a delivery failure flips the run to on_failure.

Monitoring recipe

python
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler

def delivered(result):
    print("Reached the user:", result)

def undelivered(reason):
    print("Never reached the user:", reason)  # fires on delivery failure

agent = Agent(name="Brief", instructions="Summarise the morning news")
scheduler = AgentScheduler(
    agent,
    task="Morning brief",
    deliver="telegram:123456",
    on_success=delivered,
    on_failure=undelivered,
)
scheduler.start("hourly")

Reading the outcome from stats

python
stats = scheduler.get_stats()
attempted     = stats["delivered_deliveries"] + stats["undelivered_deliveries"]
quiet_runs    = stats["successful_executions"] - attempted   # SUPPRESSED + NOT_CONFIGURED
delivery_rate = (
    stats["delivered_deliveries"] / attempted * 100 if attempted else None
)
print(stats["delivered_deliveries"])    # runs that reached the target
print(stats["undelivered_deliveries"])  # runs whose delivery failed — surfaced, never silent

undelivered_deliveries counts runs the executor completed but delivery dropped — you can have successful_executions == total_executions and still see a non-zero undelivered_deliveries.

MESSAGE_UNDELIVERED hook

What did not change


Creation-time validation and preview

A scheduled job now pre-flights its delivery target at creation, so “where will this go?” is answered before it ever fires — an unroutable token surfaces an actionable warning the moment you create the schedule, instead of being silently dropped at fire time.

python
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler

agent = Agent(name="Brief", instructions="Summarise the morning news")
AgentScheduler(agent, task="Morning brief", deliver="telegram:123456").start("hourly")
# → logs: Scheduled -> telegram:123456
Diagram

Yes

No

📝 Create schedule
deliver=’telegram:123456′

🔀 SchedulerDelivery.validate

Routable?

✅ log ‘Scheduled -> telegram:123456’

🟥 log WARN + reason + hint

The pre-flight runs the moment AgentScheduler (or the SchedulerDelivery wrapper) is constructed — before the first tick.

Diagram
ChannelDeliveryRouterSchedulerDeliveryCLI / PythonUserChannelDeliveryRouterSchedulerDeliveryCLI / PythonUseralt[routable][unroutable](later, at fire time)create schedule (deliver=”telegram:xxx”)construct(target)validate()log “Scheduled -> telegram:xxx”log WARN “not routable: <reason> <hint>”deliver(target, payload)send_message

Which primitive do I reach for?

Diagram

See where a target will go

Check if a target is reachable

Fail hard on a bad target

Writing a custom resolver

What do you need?

DeliveryTarget.preview

create AgentScheduler
→ logs DeliveryValidation

raise / catch ScheduleTargetError

implement optional
validate_target / preview_target

Preview grammar

DeliveryTarget.preview() is pure and dependency-free — nothing is fetched, so it is safe to call before the gateway is up. It renders the resolved destination as one of:

RenderingWhen
platform:channel_idExplicit channel (e.g. telegram:@alice)
platform:channel_id:thread_idExplicit channel + thread (e.g. telegram:123:789)
origin / allSymbolic routing tokens, preserved verbatim
<unrouted>An empty/no-op target with no resolvable destination

Pass session_target="main" (or "isolated") to append a (session <id>) suffix when the caller knows the session hint.

python
from praisonaiagents.scheduler import DeliveryTarget

target = DeliveryTarget.parse("telegram:@alice")
print(target.preview())                       # → telegram:@alice
print(target.preview(session_target="main"))  # → telegram:@alice (session main)

The validation result

Creating an AgentScheduler with a deliver= token pre-flights it and logs the outcome. That outcome is a frozen DeliveryValidation — the structured answer to “will this route?” — with four fields:

FieldTypeMeaning
okboolTrue when the target resolves to a reachable route
reasonstrOn failure, why it is unroutable (empty when ok)
hintstrOn failure, the actionable next step (empty when ok)
previewstrDry-run preview of the destination

A routable target logs Scheduled -> <preview> at info level; an unroutable one logs a warning carrying reason and hint.

python
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler

agent = Agent(name="Brief", instructions="Summarise the morning news")

# Routable → logs: Scheduled -> telegram:123456
AgentScheduler(agent, task="Morning brief", deliver="telegram:123456").start("hourly")

# Unroutable → logs a warning with the reason and an actionable hint
AgentScheduler(agent, task="Morning brief", deliver="all").start("hourly")

Fail-fast with ScheduleTargetError

Creating a scheduler with an unroutable token logs an actionable warning but does not raise — so a caller that only wants a preview isn’t blocked. When you want hard-fail behaviour, raise ScheduleTargetError yourself with the structured reason / hint:

python
from praisonaiagents.gateway import ScheduleTargetError

def require_routable(preview: str, reason: str = "", hint: str = "") -> None:
    if reason:
        raise ScheduleTargetError(reason, hint)

try:
    require_routable(
        preview="all",
        reason="symbolic target 'all' cannot be resolved by the lightweight path",
        hint="Use a 'platform' or 'platform:channel_id' token.",
    )
except ScheduleTargetError as e:
    # e.reason and e.hint are structured, actionable strings
    print(f"cannot schedule: {e.reason} — {e.hint}")

Custom resolvers

A custom delivery resolver can opt into creation-time pre-flight by implementing the optional validate_target() and preview_target() methods from DeliveryPreflightProtocol. Both are backward-compatible — a resolver that implements only resolve() still satisfies DeliveryResolverProtocol and keeps working exactly as before; callers duck-type on the pre-flight methods and fall back to the structural DeliveryTarget.preview() check when they are absent. See the scheduler SDK reference for the exact signatures.


Reliability Guarantees

Delivery reuses the existing DeliveryRouter, so scheduled sends inherit the gateway’s guarantees.

GuaranteeWhat it means
Creation-time pre-flightUnroutable delivery tokens surface a warning the moment the schedule is created — no more silent drops on the first fire. Fire-time dead-target self-heal remains the second line of defence. See Creation-time validation and preview.
Rate limitingInherits the router’s per-platform token-bucket limits
Idempotency dedup — every path (lightweight AgentScheduler, out-of-process tick, gateway-hosted)Durable, restart-safe. A shared SqliteIdempotencyStore at ~/.praisonai/state/delivery.db (honours $PRAISONAI_HOME) records every successful send and reserves in-flight keys, so a re-fired job across a crash/restart is deduplicated exactly once — regardless of which internal path performed the send. Falls back to the router’s in-process LRU only if the durable store cannot be built (missing praisonai-bot extra, unwritable state dir). See Restart-safe delivery.
Gateway-hosted queueThe gateway’s OutboundQueue at ~/.praisonai/state/gateway_outbox.sqlite still owns queuing (retryable enqueue, ordered drain, dead-letter). The dedup substrate is now the shared delivery.db above, not queue-specific.
Dead-target self-healThe router retries and skips/marks dead targets
Graceful degradationWhen praisonai-bot is not installed, a single warning is logged and delivery no-ops — the scheduled run itself never fails
Out-of-process deliveryWhen no live delivery_handler is wired (cron / CI / serverless), delivery falls back to a per-platform standalone HTTPS sender (Telegram / Slack / Discord / WhatsApp / Signal). Uses only the platform’s own env ({PLATFORM}_BOT_TOKEN, or the WhatsApp / Signal env vars) — no adapter, no persistent process. Each send is retried in-process up to 4 times with exponential backoff on transient failures (5xx / 429 / network), honouring a server-mandated Retry-After; a permanent failure (bad token, 4xx) records delivery_error on the first attempt. See Out-of-process delivery and Bounded in-process retry.
Intentional silenceAn exact NO_REPLY / [SILENT] / SILENT output suppresses delivery; the run is still recorded as succeeded
Continuable deliveryOn successful delivery of a continuable target, the gateway seeds a resumable session keyed to the reply address. A seed failure logs and never breaks the delivery — the brief still lands.
No silent dropsA configured delivery target that cannot be delivered (missing token, unsupported platform, Slack logical error, etc.) is recorded as delivery_error on the run — never silently dropped.

Idempotency dedup is now durable on every path — the router is injected with a shared SqliteIdempotencyStore at ~/.praisonai/state/delivery.db. Its reserve() claims an in-flight key, record() commits it after a confirmed send, and release() frees it on failure so a retry is never deduplicated against its own crashed attempt. The exact crash window a scheduler must survive (fire → deliver → crash → restart → re-fire) is deduplicated without a double-post, whether the send came from the lightweight scheduler, the out-of-process tick, or the full gateway. The gateway’s OutboundQueue at ~/.praisonai/state/gateway_outbox.sqlite still owns queuing (retryable enqueue, ordered drain, dead-letter); a pre-crash send that never landed stays retryable and is delivered at-least-once on the next tick. If the durable store cannot be built (missing dependency or permission error) the router falls back to its in-process LRU exactly as before.

Diagram

no live handler

retry ×4 on transient

✅ Result

💾 delivery.db
durable dedup
reserve · record · release

💾 gateway_outbox.sqlite
queuing only

🔀 DeliveryRouter

⏱️ Rate limit

🩹 Dead-target self-heal

🌐 standalone sender
Telegram · Slack · Discord · WhatsApp · Signal

✅ chat

📦 bot not installed

⚠️ Warn + no-op


Common Patterns

Deliver from a blueprint

python
from praisonai.scheduler import AgentScheduler

scheduler = AgentScheduler.from_blueprint(
    "morning-brief",
    slots={"hour": 8, "weekdays": "mon-fri"},
    deliver="telegram",
)
scheduler.start(scheduler._yaml_schedule_config["interval"])

Inside a running event loop, use the async twin — same signature, dispatched via astart():

python
import asyncio
from praisonai.scheduler import AsyncAgentScheduler

async def main():
    scheduler = AsyncAgentScheduler.from_blueprint(
        "morning-brief",
        slots={"topic": "AI"},
        deliver="telegram:123456",  # overrides blueprint default
    )
    await scheduler.start_from_yaml_config()

asyncio.run(main())

deliver= on both AgentScheduler.from_blueprint and AsyncAgentScheduler.from_blueprint overrides the blueprint’s default delivery target.

Deliver to a thread

python
from praisonaiagents import Agent
from praisonai.scheduler import AgentScheduler

agent = Agent(name="Standup", instructions="Post the daily standup summary")
AgentScheduler(agent, task="Standup", deliver="discord:555:thread-42").start("daily")

Attachments produced by the job (media returned by the agent) now travel into the same thread — parity with text delivery is preserved on Slack, Telegram, and Discord. A multi-step (text + media) envelope that fails mid-send releases its durable reservation, so its own retry is not suppressed as a duplicate. See Outbound Media Delivery for the thread grammar and Restart-safe delivery for the dedup contract.

Fire-and-forget notice

bash
praisonai schedule add "backup-notice" -s daily \
  -m "post the backup-complete notice" \
  --deliver slack:C0123456 \
  --no-continuable

Use --no-continuable when a reply should not resume the job — the reply starts a fresh session, matching pre-#3449 behaviour.

Out-of-process cron entry

Deliver a morning brief without keeping a gateway process alive — set the token env vars in the crontab and call praisonai schedule tick.

bash
# crontab -e
# Deliver a morning brief without keeping a gateway process alive.
TELEGRAM_BOT_TOKEN=123456:AAE...
TELEGRAM_HOME_CHANNEL=-100555
0 8 * * * cd /srv/praisonai && praisonai schedule tick
yaml
# .praisonai/schedules/morning-brief.yaml (or the same YAML you'd use elsewhere)
name: morning-brief
schedule:
  cron: "0 8 * * *"
message: "Summarise overnight alerts"
deliver: "telegram"          # → resolved to TELEGRAM_HOME_CHANNEL out-of-process

See Out-of-process delivery for the full contract.


Best Practices

Use an explicit channel ID for scheduled jobs

telegram:123456 targets a fixed chat and works without any request context.

origin now works on the lightweight scheduler path when the job has a persisted origin (ScheduleJob.origin, set automatically when the job is created from a bot/webhook request) — it resolves to the same channel/thread the request came in on, no gateway required. all still needs the full gateway. For jobs created outside a bot request (no persisted origin), prefer an explicit platform:channel_id token.

Install the bot extra to enable delivery

Delivery needs the praisonai-bot package. Without it, the scheduled run still executes — only the push is skipped, with one warning. With the package installed you get two delivery paths automatically: the live gateway path when a delivery_handler is wired, and the standalone sender for Telegram / Slack / Discord / WhatsApp / Signal when running unattended (cron / CI / serverless). Each path honours the platform’s own env ({PLATFORM}_BOT_TOKEN, or the WhatsApp / Signal env vars). See Out-of-process delivery.

Reuse one scheduler instance per job

The per-platform token-bucket rate limiters live on the scheduler’s delivery helper — keep a single scheduler running so a burst of repeated sends to the same channel is smoothed across ticks. Dedup itself is now durable: a fresh scheduler instance still dedupes across restarts via the shared delivery.db, so reuse is a rate-limiter optimisation, not a correctness requirement for dedup. See Restart-safe delivery.

Pick the surface that matches how you deploy

Use Python inside an app, YAML for a config-file deploy, and the CLI for one-off terminal jobs — all three accept the same deliver token grammar.


Related