Since PraisonAI PR #1566, exceptions inside a scheduled run are caught and reported via
on_failure without killing the scheduler loop. Use await scheduler.get_stats_async() for metrics from async code.Agent-scheduler runs inherit the same at-most-once claim behavior on the default store — a due job fires on only one worker per tick even with several processes polling the same store. Both bundled stores (
ConfigYamlScheduleStore and FileScheduleStore) implement claim_due, so this holds out of the box; it only becomes conditional if you bring a custom store that opts out. See Multi-process safety.Quick Start
1
Simple Usage
2
With Callbacks
3
With Timeout & Budget Limit
4
Deliver Results to a Chat Channel
AsyncAgentScheduler accepts the same deliver= parameter as the sync scheduler and dispatches delivery through asyncio.to_thread — the shared helper uses the sync bridge, so delivery never blocks the event loop and never raises. See Scheduler → Deliver Results for the full token grammar and the praisonai[bot] optional dependency.How It Works
The AsyncAgentScheduler uses async-native execution with cooperative cancellation, replacing the old thread-based scheduler.The scheduler’s async primitives (
_stop_event, _cancel_event, _stats_lock) are now created lazily inside _ensure_async_primitives() and bound to the loop that start() runs on. Tests that call stop() without first calling start() must invoke scheduler._ensure_async_primitives() explicitly — see tests/unit/scheduler/test_async_agent_scheduler.py in PR #1583 for the canonical pattern.Shared dispatch helper (PR #2147): AsyncPraisonAgentExecutor.execute() now delegates to the shared adispatch_agent helper (praisonai.scheduler._dispatch). No behaviour change — the dispatch ladder (astart → to_thread(start) → AttributeError) is unchanged. See also the sync scheduler note for the corresponding fix to AgentScheduler.Schedule Expression Reference
Legacy forms above are unchanged — every one keeps its existing behaviour.
Timezones
A naiveat: timestamp (no Z, no +HH:MM offset) and every cron: expression resolve their timezone in this order:
- The
tzfield on the schedule (per-schedule). - The
default_timezoneargument on the scheduler / agent (per-process default set in code). - The
PRAISONAI_SCHEDULE_TIMEZONEenvironment variable (process-wide default). UTC(fallback when none of the above is set).
An
at: string that already carries an offset (at:2026-03-01T09:00:00+05:30, at:2026-03-01T09:00:00Z) is unaffected — the offset in the string always wins.PRAISONAI_SCHEDULE_TIMEZONE example.
Natural language
Plain English clock times and day names parse into the same one-shot and cron schedules — no prefixes required.
Natural-language expressions. A bare clock time (
at 9am) is treated as a one-shot at the next matching local time in the schedule’s timezone. Any recurring form — every …, daily …, or one that names a day of the week — is translated to a cron expression and follows the same wall-clock semantics as cron:. The croniter engine that evaluates these expressions ships with praisonaiagents by default. If it is somehow missing (a stripped install), parse_schedule() raises ValueError at creation time with an install hint — no more silent “accepted but never fires”.
Day-of-week names accept full or abbreviated forms (monday/mon, tuesday/tue/tues, thursday/thu/thurs, …), plus weekday(s) → 1-5 and weekend(s) → 0,6. A bare clock time is timezone-aware: at 9am denotes 09:00 local time in the schedule’s timezone, not 09:00 UTC.
Timezones
A naiveat: timestamp and the clock forms (at 9am, at 17:00) resolve their zone in order: the schedule tz → the instance default → PRAISONAI_SCHEDULE_TIMEZONE → the machine’s local zone. An at: string that already carries an offset (+05:30, Z) is used verbatim. cron: follows the same order but falls back to UTC, not local.
- Stored value is aware. As of PraisonAI #4732,
parse_schedulestamps a naiveat:with the resolved zone at parse time, so the stored ISO string names an unambiguous instant and fires at the same wall-clock time on any runner. - DST is per target date. The offset attached is the one in force on the target date. In Europe/London,
at:2026-07-01T09:00:00stamps+01:00andat:2026-12-01T09:00:00stamps+00:00, whichever day you parse it. - Bad zone names fail loudly. An unknown IANA zone in
PRAISONAI_SCHEDULE_TIMEZONE(ortz=) now raisesValueErrorat parse time instead of being accepted and never firing.
Wall-clock cron
cron: schedules honour the real time-of-day, not the interval since process start.
- Time-of-day accuracy —
cron:0 9 * * *fires at 09:00 wall-clock every day. A restart at 09:30 does not re-phase the schedule to 09:30. - Downtime catch-up (exactly once) — if the process was down when a slot was due (e.g. a scale-to-zero window), the schedule catches up once on resume, then re-anchors to the next future occurrence. Never double-fires the same slot.
- State file — the daemon persists
last_run_atto~/.praisonai/schedulers/<name>.jsonso restarts pick up the schedule exactly where they left off. - Bundled engine — the
croniterengine that powers wall-clock cron ships withpraisonaiagentsby default, socron:schedules work out of the box with no extra install step.
Two code paths, one engine. When you create a schedule through
praisonaiagents.scheduler.parser.parse_schedule (the core parser), a cron: or natural-language recurring form is refused at creation with a clear ValueError if croniter is somehow missing on a stripped install — it is never accepted and silently skipped. The praisonai schedule daemon ticker (ScheduleTicker) is a separate wrapper path: if it can’t import croniter at run time it degrades a cron: schedule to a process-relative interval and logs a one-time warning. Both behaviours only matter on a stripped install — a default praisonaiagents install includes croniter.hourly, */30m, raw seconds) are unchanged — they keep the fixed-interval sleep behaviour.
Introduced in PraisonAI PR #3527.
Since PraisonAI PR #2916, the wrapper delegates the shared grammar (
hourly / daily / weekly / */N{m,h,s} / raw seconds) to praisonaiagents.scheduler.parser.parse_schedule, so weekly is now accepted by every wrapper scheduler (AgentScheduler, AsyncAgentScheduler, DeploymentScheduler, and the praisonai schedule CLI). The bare */N (unit-less) fast-path stays local to the wrapper.Configuration Options
AsyncAgentScheduler Constructor
Fixed in PraisonAI #3420:
max_cost=0 is now a real (zero) budget — the scheduler trips immediately instead of running unbounded (previously 0 was silently treated as “no cap”). Combined with run_immediately=True, the immediate run trips the brake, so await scheduler.start(...) returns False and does not spin up the background task. Check the return value before assuming the scheduler is live.start() Method Options
start() returns a bool: True when the background task is running, False when it never starts — already running, a parse error, or an immediate run that tripped the budget brake (see the max_cost note above).
Reading Stats
Statistics can be read in both sync and async contexts with different guarantees:Stats Response Format
Both counters are explicit:
SUPPRESSED and NOT_CONFIGURED runs contribute zero to each, so delivered_deliveries + undelivered_deliveries ≤ successful_executions. See the per-outcome counter table for which cell each run lands in.
Since PR #1857, AsyncAgentScheduler shares its stats builder with the sync AgentScheduler via the internal _BaseAgentScheduler mixin — the schema is now identical across sync and async.
Async Blueprints
AsyncAgentScheduler.from_blueprint(...) builds a scheduler from a named blueprint template, dispatching the blueprint prompt via astart() so scheduled runs never block the event loop.
Blueprint resolution lives in the shared _base_scheduler.build_from_blueprint, so the async and sync surfaces stay in lock-step. from_blueprint also populates _yaml_schedule_config, so start_from_yaml_config() starts the scheduler on the blueprint’s resolved interval.
1
Build from a blueprint
2
Override interval and delivery
from_blueprint() Parameters
from_blueprint raises ValueError if the blueprint is not found or a required slot is missing.
New in PR #4336:
AsyncAgentScheduler.from_blueprint(...) restores surface parity with the sync AgentScheduler.from_blueprint(...). Blueprint resolution now lives in the shared _base_scheduler.build_from_blueprint, so any future blueprint change lands in both schedulers at once. The async wrapper (AsyncBlueprintAgent) exposes both astart (used by the async scheduler) and a sync start fallback, so it never blocks the event loop.Common Patterns
Running in FastAPI Application
Error Handling with Logging
Graceful Shutdown on SIGINT
Budget-aware Scheduling
Real cost tracking (PR #2171): Before #2171, both schedulers added a fixed
$0.0001 to total_cost per run, so the default max_cost=1.00 only tripped after ~10,000 runs regardless of model. The scheduler now pulls usage (input/output tokens) and model off the agent response and prices it through praisonai.cli.features.cost_tracker.ModelPricing. Responses with no usage metadata contribute $0 — the brake errs on the side of running rather than tripping on missing data. Negative token counts are clamped to 0 so they can never bypass the brake.Daemon state persistence
WhenAsyncAgentScheduler runs as a daemon (started via praisonai schedule start <name> ...), it now updates ~/.praisonai/schedulers/<name>.json after every execution with the current executions count and cost. The file I/O is offloaded with asyncio.to_thread() so the event loop is never blocked. Previously these writes were a TODO and async daemons silently reported stale numbers. See PR #1857.
As of PraisonAI #4537, those writes are atomic — the state file goes to a temp file, is
fsync’d, then renamed into place with os.replace. A crash mid-write can no longer leave a truncated JSON file that silently disables cron catch-up on next start. Corrupt or unreadable state files now log a warning (Corrupt scheduler state ... / Cannot access scheduler state ...) and the scheduler re-anchors from process start. See Async Scheduler → Daemon state persistence.Best Practices
Always await scheduler.stop() before exiting
Always await scheduler.stop() before exiting
The
stop() method waits up to 30 seconds for the current execution to complete before canceling. This prevents data corruption and ensures clean shutdown.As of PraisonAI #4537,
await scheduler.stop() also interrupts an in-flight retry backoff immediately — it no longer holds for the full backoff cap (default 300 s). Shutdown is bounded by the current in-flight run, so SIGTERM’d containers exit inside the normal grace window. Same behaviour as the sync interruptible backoff.Use run_immediately=True for testing
Use run_immediately=True for testing
Enable
run_immediately=True to verify your agent works correctly before waiting for the first scheduled interval.Keep callbacks lightweight
Keep callbacks lightweight
Success and failure callbacks are called synchronously. Heavy operations should be offloaded to avoid blocking the scheduler.
Prefer AsyncAgentScheduler over legacy thread-based scheduler
Prefer AsyncAgentScheduler over legacy thread-based scheduler
For new code, use
AsyncAgentScheduler instead of the legacy AgentScheduler. The async version provides better cancellation, no daemon threads, and fits naturally into async applications.Use the async blueprint entry point inside a running loop
Use the async blueprint entry point inside a running loop
Inside FastAPI or any running event loop, build schedulers with
AsyncAgentScheduler.from_blueprint — the blueprint prompt dispatches through astart() and never blocks. The sync AgentScheduler.from_blueprint runs threading.Event.wait() under the hood and stalls the loop each tick.Budget Control
Budget Control
The default This is the same code path as any other budget trip, so
max_cost=1.00 caps unattended cost runaway. Since PR #2171, total_cost_usd is the real per-token spend computed from each response’s usage field — pick a value that matches your approved budget, not a multiple chosen to compensate for an undercount.stats["is_running"] flips to False and the stop event fires — useful for smoke-testing the schedule and delivery wiring without spending anything.When the budget triggers, the scheduler logs a warning and calls stop_event.set() internally — stats["is_running"] flips to False.If your model is missing from DEFAULT_PRICING, the run is priced at $0 and total_cost_usd will under-report — register it via the custom pricing snippet so the brake stays meaningful.Timeout Configuration
Timeout Configuration
Set
timeout to bound the worst-case wall-clock time per run. Internally implemented with asyncio.wait_for(), which raises asyncio.TimeoutError and triggers the standard retry path.timeout=None (the default) imposes no limit.Wall-clock cron works out of the box
Wall-clock cron works out of the box
cron: schedules honour wall-clock time using the croniter engine, which ships with praisonaiagents by default — no separate install step. On a stripped install without it, the praisonai schedule daemon ticker logs a one-time warning and falls back to a coarse interval anchored to process start, while the core parse_schedule() refuses the cron schedule at creation with a ValueError.Import paths (updated in PR #1723):
- Canonical (recommended):
from praisonai.scheduler import AsyncAgentScheduler - Explicit module path:
from praisonai.scheduler.async_agent_scheduler import AsyncAgentScheduler - Deprecated (still works, emits
DeprecationWarning):from praisonai.async_agent_scheduler import AsyncAgentScheduler
AgentScheduler is unchanged: from praisonai.scheduler import AgentScheduler.Skipping Ticks with a Pre-Run Gate
A pre-run gate lets you run a cheap shell check before each scheduled tick — the agent only fires when the check says there’s something to do. This cuts token spend on quiet ticks (e.g. polling for new emails every 5 minutes but only summarising when mail actually arrives). See Scheduler Pre-Run Gate for the full configuration reference and examples.Delivering Results to a Chat
Passdeliver= to push each successful result to Telegram, Discord, Slack, or WhatsApp — no gateway required.
A scheduled run whose whole output is exactly
NO_REPLY (or [SILENT] / SILENT) skips delivery — the tick still succeeds and is recorded in stats, but nothing is pushed. Both AgentScheduler and AsyncAgentScheduler share this check through _BaseAgentScheduler._should_suppress_delivery, so it holds uniformly on sync and async schedules. See Scheduler Delivery → Intentional Silence.Before PraisonAI #3420, the async path pushed the literal marker string every tick — the check now lives on the shared base, so async schedules stay quiet too.For a run whose delivery fails (rather than being intentionally suppressed), on_failure now fires instead of on_success, and the run counts as undelivered_deliveries. See Scheduler Delivery → Delivery outcomes.Related
RunPolicy adds run-scoped guardrails (tool scoping, prompt scanning, durable audit) to ScheduledAgentExecutor. AsyncAgentScheduler is independent of RunPolicy — see Scheduled Run Policy for how to add guardrails when using ScheduledAgentExecutor or EnhancedScheduledAgentExecutor.AsyncAgentScheduler lives in the praisonai wrapper (from praisonai.scheduler import AsyncAgentScheduler). ScheduledAgentExecutor and JobResult live in the bot tier — use from praisonai_bot.scheduler import ScheduledAgentExecutor, JobResult when using them directly.Scheduler CLI
Command-line interface for scheduling agents
Pre-Run Gate
Skip ticks when a cheap check says nothing to do
Scheduler Delivery
Push scheduled results to Telegram/Discord/Slack/WhatsApp
Background Tasks
Running agents as background processes
Run Policy
Scope tools, scan prompts, and audit output for unattended runs
Multi-Tenant Scheduler
Isolate each gateway user’s jobs with a principal owner key

