Scheduler Providers

Choose when scheduled jobs fire — in-process poll (default), systemd/launchd timer, cloud webhook, cron, or serverless

A scheduler provider decides when to fire. The default polls in-process; swap it for systemd, a cloud scheduler webhook, or a cron trigger without changing what fires.

Diagram

on_due

⏰ Provider — when

⚙️ ScheduleRunner.claim_due

🗄️ ScheduleStore

🔔 on_trigger job

A provider never decides what fires — it just calls on_due whenever a tick should occur, and the shared runner and store claim and fire due jobs.

Which Provider?

Pick in-process when one host runs all the time; pick an external provider when firing should come from elsewhere.

Diagram

Always-on process, one host

Many hosts / serverless / existing scheduler

How does your app run?

In-process default
daemon poll thread

External provider
webhook · systemd · cron · K8s CronJob

Quick Start

  1. Default — in-process poll (nothing to configure)
    python
    from praisonaiagents import Agent
    from praisonaiagents.scheduler import ScheduleLoop
    
    agent = Agent(name="assistant", instructions="You set reminders.")
    
    loop = ScheduleLoop(on_trigger=lambda job: agent.start(job.message))
    loop.start()  # zero-arg — same as before
  2. External / serverless — event-driven, no always-on thread
    python
    from praisonaiagents import Agent
    from praisonaiagents.scheduler import ScheduleLoop
    
    agent = Agent(name="assistant", instructions="You set reminders.")
    
    # Reuse the built-in loop as a pure "claim + fire" engine — do NOT call start().
    engine = ScheduleLoop(on_trigger=lambda job: agent.start(job.message))
    
    # In your webhook / cron / systemd-timer handler:
    def on_cloud_scheduler_ping():
        engine.fire_due()  # one tick — no daemon thread

How It Works

The provider owns the trigger; the runner and store own the firing.

Diagram
AgentStoreRunnerProviderAgentStoreRunnerProvideron_due() (tick)claim_due()due jobson_trigger(job)done

on_due is the seam: the provider calls it whenever its trigger fires, and everything downstream — claiming due jobs and firing them — happens in the runner and store.

PieceOwns
Provider (SchedulerProviderProtocol)When a tick happens
ScheduleRunner + storeWhat is due and how it fires

Provider Options

Two ways to drive firing — one always-on, one event-driven.

ProviderWhen to useAlways-on thread?
InProcessScheduleProvider (default, alias for ScheduleLoop)Single always-on processYes (daemon)
External (webhook / systemd / cron / K8s CronJob / APScheduler)Multi-host, serverless, or delegate to existing schedulerNo

Any object with start(on_due, store=...) and stop() satisfies SchedulerProviderProtocol.

python
from praisonaiagents.scheduler import (
    SchedulerProviderProtocol,
    InProcessScheduleProvider,
    ScheduleLoop,
)

assert InProcessScheduleProvider is ScheduleLoop
assert isinstance(ScheduleLoop(on_trigger=lambda job: None), SchedulerProviderProtocol)

Sharing the store across providers

When a webhook, systemd timer, or K8s CronJob triggers engine.fire_due() and the gateway or wrapper is also polling, all of them should read the same store so a job authored anywhere is claimed once and only once.

python
from praisonaiagents.scheduler import ScheduleLoop, get_default_store

engine = ScheduleLoop(
    store=get_default_store(),         # share the canonical default
    on_trigger=lambda job: handle(job),
)

For deployments that need a different backend at process start, override once — every downstream consumer (agent tools, gateway tick, host bridge) picks it up.

python
import os
from praisonaiagents.scheduler import set_default_store
from myproject.stores import PostgresScheduleStore

set_default_store(PostgresScheduleStore(dsn=os.environ["DB_URL"]))

All three consumers resolve their store the same way, so a job written on any surface fires on every tick loop.

Diagram

🛠️ Agent Tools

🗄️ get_default_store

⏰ Gateway Tick

🔗 Host Bridge

🔔 Fired Job


User Interaction Flow

A reminder set through the agent still fires even when a cloud scheduler drives the tick.

Diagram
ScheduleLoopCloud SchedulerAgentUserScheduleLoopCloud SchedulerAgentUser09:00 — external trigger“Remind me at 9am”schedule_add(…)POST → fire_due()on_trigger(job)“Here’s your 9am reminder”

Configuration Options

Providers are protocol-only — see the auto-generated SDK reference for full signatures.


Common Patterns

Cloud webhook

Cloud Scheduler, EventBridge, or a GitHub Actions cron posts to an HTTP handler that calls fire_due().

python
from praisonaiagents import Agent
from praisonaiagents.scheduler import ScheduleLoop

agent = Agent(name="assistant", instructions="You set reminders.")
engine = ScheduleLoop(on_trigger=lambda job: agent.start(job.message))

# e.g. FastAPI / Flask route the cloud scheduler pings on a schedule
def handle_scheduler_webhook():
    engine.fire_due()
    return {"ok": True}

systemd / launchd timer

A timer unit runs a short command on schedule; the command fires one tick and exits.

python
# fire_once.py — invoked by a systemd timer (OnCalendar=*:0/5)
from praisonaiagents import Agent
from praisonaiagents.scheduler import ScheduleLoop

agent = Agent(name="assistant", instructions="You set reminders.")
ScheduleLoop(on_trigger=lambda job: agent.start(job.message)).fire_due()

Long-running poller under any supervisor

praisonai schedule run is a zero-dependency way to host the default in-process poller under any process supervisor (systemd, pm2, tmux, foreman) — no need to run the whole BotOS gateway. It calls ScheduleLoop.run_forever(poll_seconds=…) in the foreground.

ini
# /etc/systemd/system/praisonai-schedules.service
[Unit]
Description=PraisonAI standalone schedule poller
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/praisonai schedule run --poll 15
Restart=on-failure
User=praisonai

[Install]
WantedBy=multi-user.target

Kubernetes CronJob

A short-lived pod runs fire_due() and exits — no long-running scheduler pod.

python
# entrypoint.py — the CronJob pod's command
from praisonaiagents import Agent
from praisonaiagents.scheduler import ScheduleLoop

agent = Agent(name="assistant", instructions="You set reminders.")
ScheduleLoop(on_trigger=lambda job: agent.start(job.message)).fire_due()

Best Practices

Never call start() twice on the same loop

start() on a running loop is a no-op by design. Treat one loop as one provider — don’t try to reconfigure it mid-run.

Use fire_due() directly for serverless

In webhook, cron, or systemd handlers call engine.fire_due() and never call start() — you get one tick’s work with no daemon thread.

Concurrency is safe by default

Both bundled stores (ConfigYamlScheduleStore — the default — and FileScheduleStore) implement atomic claim_due, so a due job fires at most once across processes and hosts out of the box. fire_due() also runs its claim + fire step under a per-instance lock, guarding against double-fires within a single process.

OS advisory locks only hold on filesystems that honour them — NFS does not reliably, so shared-NFS deployments can still double-fire.

Pair external providers with claim_due stores

Both bundled stores already implement claim_due, so cross-host at-most-once delivery works by default. If you bring a custom store, implement claim_due on it to keep the same guarantee when firing from multiple hosts.

Share get_default_store() across every runtime

The unified-store fix in PraisonAI #3266 relies on writer and reader landing on the same backend. If you construct ConfigYamlScheduleStore() in one place and FileScheduleStore() in another, jobs authored on one side silently miss the ticker on the other. Resolve every consumer through get_default_store().


Related