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.
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")“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
- 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") - 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") - 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. - 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
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
# 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
praisonai schedule add "morning-brief" \
-s hourly \
-m "Morning brief" \
--deliver telegram:123456Tools 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.
# 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?
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)| Token | Meaning |
|---|---|
"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.
| Platform | thread_id semantics |
|---|---|
| Slack | Parent message ts (thread timestamp) |
| Telegram | Forum topic message_thread_id |
| Discord | Thread channel ID |
| Ignored (no thread concept) | |
| Others | Silently ignored — adapters without a thread_id kwarg are unaffected |
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.
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.
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:
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.
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:
# Every hour, out of process, no gateway
0 * * * * TELEGRAM_BOT_TOKEN=xxxx praisonai schedule tickBefore 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.
- Set the platform token
bash # Same env the live gateway uses — read at send time, not import. export TELEGRAM_BOT_TOKEN=123456:AAE... - 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.
| Platform | Token env | Home-channel env | Platform API endpoint | Size limit | Thread field |
|---|---|---|---|---|---|
telegram | TELEGRAM_BOT_TOKEN | TELEGRAM_HOME_CHANNEL | POST https://api.telegram.org/bot{token}/sendMessage | 4096 chars | message_thread_id |
slack | SLACK_BOT_TOKEN | SLACK_HOME_CHANNEL | POST https://slack.com/api/chat.postMessage (Bearer auth) | 39000 chars | thread_ts |
discord | DISCORD_BOT_TOKEN | DISCORD_HOME_CHANNEL | POST https://discord.com/api/v10/channels/{id}/messages (Bot auth) | 2000 chars | message_reference (fail_if_not_exists=false) |
whatsapp | WHATSAPP_ACCESS_TOKEN + WHATSAPP_PHONE_NUMBER_ID | WHATSAPP_HOME_CHANNEL | POST https://graph.facebook.com/v20.0/{phone_number_id}/messages (Bearer auth) | 4096 chars | (ignored — see per-platform note) |
signal | SIGNAL_ACCOUNT, SIGNAL_BRIDGE_URL (default http://localhost:8080) | SIGNAL_HOME_CHANNEL | POST {SIGNAL_BRIDGE_URL}/v2/send | 2000 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.
| Order | Source | Why |
|---|---|---|
| 1 | Explicit channel_id on the deliver: target (telegram:123456) | Most specific — always wins. |
| 2 | {PLATFORM}_HOME_CHANNEL env var | Env-first, so a deployment can override without touching gateway state. |
| 3 | ~/.praisonai/state/home_channels.json | The 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.
| Scenario | Result |
|---|---|
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 unset | Run succeeds; delivery_error recorded ("{TOKEN} not set for standalone delivery"). |
Bare-platform target and no {PLATFORM}_HOME_CHANNEL + no registry entry | Run succeeds; delivery_error recorded. |
Unsupported platform with no standalone_sender on its descriptor | Run succeeds; delivery_error recorded. |
Custom / entry-point platform whose descriptor does declare standalone_sender | Delivered 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} body | Run succeeds; delivery_error records the Slack error name |
| Payload > platform limit | Auto-chunked into multiple messages (see per-platform table below) |
Live delivery_handler present | Live 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_id | Run succeeds; delivered=False, delivery_error records the incomplete-delivery message. |
| No live router and no registered channel bot for the target platform | Run succeeds; delivered=False, delivery_error records the incomplete-delivery message. |
deliver_on_failure=True and the failure summary reached the channel | Run 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 arrive | Run 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
| Platform | Token env | Home-channel env | Chunk limit | Thread mapping |
|---|---|---|---|---|
| Telegram | TELEGRAM_BOT_TOKEN | TELEGRAM_HOME_CHANNEL | 4096 chars | thread_id → message_thread_id (forum topic) |
| Slack | SLACK_BOT_TOKEN | SLACK_HOME_CHANNEL | 39000 chars | thread_id → thread_ts (parent message ts) |
| Discord | DISCORD_BOT_TOKEN | DISCORD_HOME_CHANNEL | 2000 chars | thread_id → message_reference reply |
WHATSAPP_ACCESS_TOKEN + WHATSAPP_PHONE_NUMBER_ID | WHATSAPP_HOME_CHANNEL | 4096 chars | Ignored — 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) | |
| Signal | SIGNAL_ACCOUNT (sender number) | SIGNAL_HOME_CHANNEL (recipient) | 2000 chars | Not applicable (Signal has no threads) |
Custom / entry-point platforms (praisonai.channels) | Set by the plugin | Set by the plugin | Set by the plugin | Supported 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.
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.
| Field | Value | Source |
|---|---|---|
Max attempts (_MAX_ATTEMPTS) | 4 | _standalone_sender.py |
| Initial backoff | 1000 ms | BackoffPolicy(initial_ms=1000, ...) |
| Max backoff | 20000 ms | BackoffPolicy(max_ms=20000, ...) |
| Backoff factor | 2.0 | BackoffPolicy(factor=2.0, ...) |
| Jitter | 0.2 (±20 %) | BackoffPolicy(jitter=0.2) |
| Retry-After | Server value wins when present | server_retry_after(exc) |
| Classifier | Shared with interactive replies | is_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.
End-to-end: cron deployment walkthrough
- 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=123456789Or, 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, whichpraisonai schedule tickreads from cron with no gateway. - Create a job with a bare platform target
bash praisonai schedule add "morning-brief" -s hourly -m "Morning brief" --deliver telegramNo explicit chat id — it resolves from the persisted registry.
- Schedule the tick in OS cron
bash # crontab -e 0 * * * * TELEGRAM_BOT_TOKEN=xxxx praisonai schedule tick - 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.
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")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.
Before / after #3449:
| Scenario | Before #3449 | After #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 required | Manually 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:
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
praisonai schedule add "backup-alert" \
-s "cron:0 3 * * *" \
-m "Backup ok" \
--deliver "slack:C012345" \
--no-continuableTool
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
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:
| Precondition | What the gateway does |
|---|---|
continuable is True (default), agent-run result | Mirrors 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 notice | Mirrors text as a user turn prefixed [cron] (authored_by="external"). The agent never thinks it wrote command output or a system notice. |
continuable is False | Skips 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. |
# 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
| Option | Where | Type | Default | Description |
|---|---|---|---|---|
continuable | DeliveryTarget(continuable=…) | bool | True | When 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. |
continuable | schedule_add(…, continuable=…) | bool | True | Threaded through to the constructed DeliveryTarget. |
--continuable / --no-continuable | praisonai schedule add | flag | --continuable | Applied 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="all")
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.
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.
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.
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.
| Agent output | Delivery |
|---|---|
NO_REPLY (exact, case-insensitive, whitespace-trimmed) | Suppressed |
[SILENT] / SILENT | Suppressed |
I think NO_REPLY is a good idea (prose) | Delivered normally |
| Any other text | Delivered 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.
| Path | Silence 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().
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 outcome | delivered_deliveries | undelivered_deliveries | Callback fired |
|---|---|---|---|
DELIVERED (router accepted) | +1 | 0 | on_success(result) |
UNDELIVERED (router returned False) | 0 | +1 | on_failure("scheduled result could not be delivered") |
UNDELIVERED (delivery raised) | 0 | +1 | on_failure("scheduled result could not be delivered") |
SUPPRESSED (NO_REPLY / [SILENT] / SILENT) | 0 | 0 | on_success(result) |
NOT_CONFIGURED (no deliver= target) | 0 | 0 | on_success(result) |
The invariant holds on both the sync and async scheduler:
delivered_deliveries + undelivered_deliveries ≤ successful_executionsThe 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
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
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 silentundelivered_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.
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:123456The pre-flight runs the moment AgentScheduler (or the SchedulerDelivery wrapper) is constructed — before the first tick.
Which primitive do I reach for?
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:
| Rendering | When |
|---|---|
platform:channel_id | Explicit channel (e.g. telegram:@alice) |
platform:channel_id:thread_id | Explicit channel + thread (e.g. telegram:123:789) |
origin / all | Symbolic 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.
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:
| Field | Type | Meaning |
|---|---|---|
ok | bool | True when the target resolves to a reachable route |
reason | str | On failure, why it is unroutable (empty when ok) |
hint | str | On failure, the actionable next step (empty when ok) |
preview | str | Dry-run preview of the destination |
A routable target logs Scheduled -> <preview> at info level; an unroutable one logs a warning carrying reason and hint.
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:
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.
| Guarantee | What it means |
|---|---|
| Creation-time pre-flight | Unroutable 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 limiting | Inherits 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 queue | The 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-heal | The router retries and skips/marks dead targets |
| Graceful degradation | When praisonai-bot is not installed, a single warning is logged and delivery no-ops — the scheduled run itself never fails |
| Out-of-process delivery | When 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 silence | An exact NO_REPLY / [SILENT] / SILENT output suppresses delivery; the run is still recorded as succeeded |
| Continuable delivery | On 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 drops | A 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.
Common Patterns
Deliver from a blueprint
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():
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
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
praisonai schedule add "backup-notice" -s daily \
-m "post the backup-complete notice" \
--deliver slack:C0123456 \
--no-continuableUse --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.
# 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# .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-processSee 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
Run agents on a recurring schedule with async execution
Pre-Run GateSkip ticks when a cheap check says nothing to do
Scheduler MonitorDeliver only when a watched source changed — silence on no_change
Feed one job’s output into the next — the final stage delivers
Schedule CLIThe --deliver / -d flag and other schedule commands
Shares the same delivery target format
DeliveryTarget SDK referenceField-level detail for DeliveryTarget, preview(), and DeliveryValidation