deliver= on a scheduled agent and each result is pushed to a chat target — no gateway required.
--no-continuable for pure notifications. See Continuable Delivery.DeliveryRouter directly.
praisonai-bot isn’t installed, delivery logs a single warning and no-ops. The scheduled run itself never fails because delivery failed. When it is installed but no live gateway is running (a plain praisonai schedule tick from OS cron / CI / serverless), delivery falls back to a stateless, token-authenticated standalone sender per platform — see Out-of-process delivery.{PLATFORM}_BOT_TOKEN — see Out-of-process delivery.Quick Start
Deliver to a specific chat
Deliver to the platform home channel
Reply back to the origin chat
Async scheduler
Three Ways to Set the Target
The same delivery token works from Python, YAML, and the CLI.- Python
- YAML
- CLI
every: is a new alias for interval: in the YAML schedule: block — both accept hourly, daily, weekly, */30m, or raw seconds.Tools in scheduled YAML
Thetools: 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.
search_tool / InternetSearchTool — every other tool was silently dropped and the agent ran with tools=[]. The scheduler now uses the canonical ToolResolver, keeping CLI, Python, and scheduled surfaces identical.Which surface fits which scenario?
Delivery Target Tokens
Thedeliver value is parsed by DeliveryTarget.parse() — the same serialisable model reused from the existing delivery machinery.
origin now works on the lightweight scheduler path when the job has a persisted origin (ScheduleJob.origin — set automatically when a job is created from a bot/webhook request). It resolves to the same channel/thread the request came in on, no gateway required. If the job has no persisted origin, the lightweight path logs a warning and skips delivery. Only all still needs the full gateway — it enumerates every registered bot.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.
telegram for discord, slack, whatsapp, or signal — the grammar is identical.
Deliver back to the origin channel
Usedeliver="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.
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.
crontab -e:
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
Run one tick from cron / CI / serverless
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 baredeliver: telegram target (no explicit chat id) resolves its channel from a home-channel env var or the gateway’s persisted state file.
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.
Failure modes
Nothing is hidden — a target that cannot be delivered is recorded asdelivery_error on the run instead of being silently dropped.
delivery_handler return-value contract (see PraisonAI #4198). A live handler tells the executor whether the payload arrived by its return value:return True→ recordeddelivered=True.return False→ recordeddelivered=Falsewith a syntheticdelivery_error(the router could not resolve, the platform is offline, etc. — a non-raising failure).return None→ recordeddelivered=True. Preserves the pre-#4198 contract for adapters that report success by simply not raising.- Raising an exception → recorded
delivered=Falsewithdelivery_error=str(exc)(unchanged).
Per-platform detail
SIGNAL_BRIDGE_URL (defaults to http://localhost:8080, matching the live Signal Bot adapter) for the signal-cli-rest-api bridge endpoint.
Telegram
SetTELEGRAM_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
SetSLACK_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
SetDISCORD_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_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.
thread_id. A scheduler thread_id is a generic conversation identifier, not a WhatsApp context.message_id. Feeding it into context.message_id would yield an invalid id the Cloud API rejects as a permanent 4xx, so the standalone sender omits context entirely — matching the live adapter (bots/whatsapp.py), which sets context only from an explicit reply_to inbound message id, never from thread_id. This is a deliberate divergence from Slack/Telegram/Discord thread mapping.Signal
End-to-end encrypted via asignal-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 durability here is a bounded in-process retry. 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-mandatedRetry-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.
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.
End-to-end: cron deployment walkthrough
Register your home channel once
/sethome inside your Telegram bot chat. This records your chat id to ~/.praisonai/state/home_channels.json.Create a job with a bare platform target
Schedule the tick in OS cron
Gateway off — cron fires — message lands
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:Quick start — nothing to do
Continuable delivery is default-on. Every scheduled job with a delivery target already resumes its conversation on reply: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:cron: schedules (like cron:0 3 * * * below) honour wall-clock time-of-day and survive restarts (introduced in PR #3527). Install croniter (pip install croniter) to enable this — without it the wrapper falls back to a coarse interval and warns once. See Wall-clock cron.- CLI
- Tool
- Python
The seed contract
Given a successful delivery oftext to channel:channel_id, the gateway seeds a resumable session:
continuable field is a declarable contract. Core (praisonaiagents/scheduler/models.py) owns the field and its round-trip; the actual seeding runs in the praisonai-bot gateway. Deployments running the scheduler without the bot wrapper (direct scheduler → agent, no gateway) still round-trip the field and honour the opt-out, but there is no session to seed.Configuration options
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
Pure status alerts
--no-continuable so the chat context stays fresh.Sub-minute ticks
Sub-minute ticks
--no-continuable (or a coarser schedule) for high-frequency jobs.Recurring briefs and digests (leave it on)
Recurring briefs and digests (leave it on)
How It Works
The scheduler runs the agent, then hands the result toSchedulerDelivery, 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 idempotency cache and rate limiters persist between ticks. 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 exactlyNO_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.
NO_REPLY control token is never posted to the channel — the tick just doesn’t produce any message.
no_change is a distinct silent-suppress outcome from a generic run=False skip. A change-detection monitor records no_change when a watched source is unchanged — the model turn never runs, so there’s nothing to deliver. Intentional silence, by contrast, runs the model and suppresses only the delivery.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.
allow_silence: true — the scheduled delivery path honours silence markers unconditionally. An unattended monitor should never post the raw control token to a channel, so there is no allow_silence toggle for the scheduler.no_change status — the delivery is suppressed silently (no ping, no tokens), distinct from a skipped gate outcome.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.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:
session_target="main" (or "isolated") to append a (session <id>) suffix when the caller knows the session hint.
The validation result
Creating anAgentScheduler 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:
Scheduled -> <preview> at info level; an unroutable one logs a warning carrying reason and hint.
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:
ScheduleTargetError subclasses ValueError, so any existing except ValueError handler keeps catching it — you can adopt the structured reason / hint fields incrementally.Custom resolvers
A custom delivery resolver can opt into creation-time pre-flight by implementing the optionalvalidate_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 existingDeliveryRouter, so scheduled sends inherit the gateway’s guarantees.
~/.praisonai/state/gateway_outbox.sqlite before it reaches the router. Its UNIQUE idempotency key survives a process restart, so the exact crash window a scheduler must survive (fire → deliver → crash → restart → re-fire) is deduplicated without a double-post. A pre-crash send that never landed stays retryable and is delivered at-least-once on the next tick. If the outbox cannot be built (missing dependency or permission error) the path falls back to the router’s in-process LRU exactly as before.
Common Patterns
Deliver from a blueprint
deliver= on from_blueprint overrides the blueprint’s default delivery target.
Deliver to a thread
Fire-and-forget notice
--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 callpraisonai schedule tick.
Best Practices
Use an explicit channel ID for scheduled jobs
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
Install the bot extra to enable delivery
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
Reuse one scheduler instance per job
Pick the surface that matches how you deploy
Pick the surface that matches how you deploy
deliver token grammar.Related
Async Agent Scheduler
Pre-Run Gate
Scheduler Monitor
no_changeContext Chaining
Schedule CLI
--deliver / -d flag and other schedule commandsGateway Inbound Hooks
DeliveryTarget SDK reference
DeliveryTarget, preview(), and DeliveryValidation
