Scheduler Monitor
Wake a scheduled agent only when a watched source changed β and tell you only what changed
Turn a scheduled agent into a monitor: probe a cheap source each tick, run the model only when it changed, and stay silent when it didn’t.
Quick Start
- Turn an agent into a monitor
Add
monitorto aScheduleJob. The agent runs only when the watched URL changes.python from praisonaiagents import Agent from praisonaiagents.scheduler import ScheduleJob, Schedule agent = Agent(name="News watcher", instructions="Summarise new items") job = ScheduleJob( name="hn-frontpage", schedule=Schedule(kind="every", every_seconds=900), message="Summarise the changes.", agent_id=agent.id, monitor={"url": "https://news.ycombinator.com/"}, ) - Watch a shell source instead
Use
commandinsidemonitorto watch any cheap shell output β a file, a database count, a health probe.python from praisonaiagents.scheduler import ScheduleJob, Schedule job = ScheduleJob( name="disk-watch", schedule=Schedule(kind="every", every_seconds=300), message="Explain the change in disk usage and flag anything urgent.", monitor={"command": "df -h /"}, )
What The User Sees
Silence is the feature β an unchanged source produces no ping and spends no tokens.
Three Outcomes
Every tick records exactly one status.
| Status | Meaning |
|---|---|
succeeded | The source changed (or first run) β the agent ran and delivered |
no_change | A watched source was unchanged β suppressed silently, no tokens, no delivery |
failed | The probe itself errored |
missed | A recurring occurrence landed while the process was down and fell outside misfire_grace_seconds β recorded instead of fired. See Misfire Policy. |
abandoned | The run was claimed but never completed (crash, OOM, or restart mid-run). A one-shot is retried once; a recurring job waits for its next slot. See Crash Recovery. |
no_change is distinct from skipped (a generic gate go/no-go) so you can tell “nothing changed” apart from “the gate said don’t run”.
How It Works
Each tick probes the source, hashes the output, and compares it to the last-seen hash held in per-job state.
| Tick outcome | What happens | Recorded status |
|---|---|---|
| Source unchanged | Model turn suppressed β no tokens, no delivery | no_change |
| Source changed | Bounded diff seeded into message, model runs once | succeeded |
| First run (no prior state) | Treated as changed β model runs, hash stored | succeeded |
| Probe errored | Prior state left untouched so a later recovery still suppresses | failed |
Configuration Options
monitor spec
monitor is a small mapping naming one cheap source to probe each tick.
| Key | Type | Description |
|---|---|---|
command | str | Shell command whose stdout is hashed and diffed each tick |
url | str | A bounded fetch whose response body is hashed and diffed each tick |
ScheduleJob field
| Field | Type | Default | Description |
|---|---|---|---|
monitor | Optional[Dict[str, Any]] | None | Change-detection source spec. None keeps the existing stateless behaviour. Omitted from serialization when unset (fully backward-compatible) |
GateResult β monitor outcome
The wrapper’s monitor gate returns a GateResult carrying the monitor-mode outcome.
| Field | Type | Default | Description | |
|---|---|---|---|---|
run | bool | True | Whether to spend the model turn this tick | |
context | `str \ | None` | None | Text seeded into the job message when run is True |
reason | `str \ | None` | None | Human-readable note recorded with the run |
no_change | bool | False | True when the watched source was unchanged β implies run=False (enforced in __post_init__) | |
state_updates | `dict \ | None` | None | Bounded key/value to persist for the next tick (last-seen hash, watermark). None means “write nothing” |
Setting no_change=True always forces run=False, so a monitor can never fire a tick the contract defines as suppressed.
from praisonaiagents.scheduler import GateResult
# unchanged source β suppress silently
GateResult(no_change=True, state_updates={"last_hash": "abc123"})
# changed source β run with a diff seeded into the message
GateResult(run=True, context="2 new releases", state_updates={"last_hash": "def456"})RunRecord.status
| Value | Meaning |
|---|---|
succeeded | The model turn ran and completed |
failed | The run raised or the probe errored |
skipped | A stateless gate said don’t run |
no_change | A watched source was unchanged (monitor mode) |
missed | A recurring occurrence expired past misfire_grace_seconds β recorded, not fired |
abandoned | A claimed run’s lease expired without completing (crash or restart mid-run) β see Crash Recovery |
Crash Recovery for In-Flight Runs
A run lost to a crash is recorded as abandoned and retried according to the job kind β no configuration needed.
| Job kind | First abandoned run | Retry abandoned too |
|---|---|---|
One-shot (once=True / delete_after_run) or max_runs met | Re-offered once (at-least-once across crashes) | Retired β two abandoned rows in history |
Recurring (every, cron) | Waits for the next slot (at-most-once per slot) | Same on every slot |
Spent job whose until deadline passed meanwhile | Retired, not re-offered | β |
A spent job is no longer deleted when it is claimed β it stays leased and is removed only once its run completes. A run that is still in flight in the same process is never re-offered by that process, even if it outlives its lease, so it cannot fire twice.
URL Monitor Reliability & Safety
Every URL probe runs behind a bounded, hardened path. These are the guarantees an operator can rely on before pointing a monitor at anything they don’t own end-to-end.
| Guarantee | Value |
|---|---|
| Total wall-clock deadline (connect + body read) | 30 s (MonitorGate(timeout=β¦)) |
| Response body cap | 64 KiB β anything larger is rejected |
| Retained preview used for diffing | 2048 B |
| Diff seeded into the agent message | up to 8000 chars |
| Schemes allowed | http, https only |
Embedded credentials (https://user:pass@β¦) | rejected before any connect |
| Private / loopback / link-local addresses | rejected before any connect |
| Connection pinning | fixed to the validated IP β a DNS re-resolve can’t smuggle in a private IP |
| TLS | full hostname + CA verification |
| Redirects | not followed |
Monitor vs Pre-Run vs Command
Three scheduler features feel similar but solve different problems.
| Feature | Kind | Fires the model? | Silence when⦠|
|---|---|---|---|
monitor | Stateful (remembers last hash) | Only when the source changed | The source is unchanged (no_change) |
pre_run | Stateless go/no-go gate | Only when the gate says “go” | Nothing to do (skipped) |
command | Model-free delivery action | Never β delivers stdout verbatim | β |
From the SDK docstring: “monitor is distinct from
pre_run(a stateless go/no-go gate) andcommand(a model-free delivery action).”
Per-Job State
A monitor needs memory across wake-ups β the last-seen hash or a watermark. The core defines the JobStateStoreProtocol contract, and the concrete ConfigYamlScheduleStore implementation now ships (PR #4057) with a 16 KiB per-job cap. The executor auto-injects a “Notepad” block into the prompt and persists GateResult.state_updates on go and no_change ticks alike.
| Method | Description |
|---|---|
get_state(job_id) | Return the persisted state dict ({} if none) |
set_state(job_id, state) | Persist bounded state for the job |
clear_state(job_id) | Drop state when the job is removed |
See Cross-Run Memory for the full notepad walkthrough β the format, how the agent writes back, and the one-shot-job caveat.
CLI, YAML, and Python Parity
The same monitor spec round-trips through every front-end.
Python
from praisonaiagents.scheduler import ScheduleJob, Schedule
job = ScheduleJob(
name="release-watch",
message="Summarise what changed.",
schedule=Schedule(kind="every", every_seconds=3600),
monitor={"url": "https://example.com/releases"},
)YAML
jobs:
- name: release-watch
message: "Summarise what changed."
schedule:
kind: every
every_seconds: 3600
monitor:
url: "https://example.com/releases"ScheduleJob.to_dict() / from_dict() only persist monitor when it’s configured, so stateless jobs stay byte-for-byte unchanged.
Best Practices
Bound your source so a large fetch can't stall the tick
The probe runs on every tick. Watch a small, cheap source β a status endpoint, a row count, a file’s modification marker β not a multi-megabyte page. A bounded source keeps the ticker responsive.
monitor={"command": "curl -sf --max-time 5 https://status.example.com/health"}Use monitor when you want silence-when-unchanged
Reach for monitor when the whole point is “only tell me when something changed” β a page, a feed, a metric. Unchanged ticks cost zero tokens and deliver nothing.
Use pre_run when you want silence-when-nothing-to-do
If the decision is a stateless go/no-go (an inbox check, a queue depth), use pre_run instead β it doesn’t need to remember a prior hash.
Let first-run seed the baseline
The first tick has no prior state, so it always runs and stores the baseline hash. Expect one delivery when a monitor job is created, then silence until the source moves.
Bound a monitor that watches for a one-off event
Recurring monitors can be bounded β see max_runs and until β so a change-detection job that watches for a one-off event can retire itself instead of ticking forever.
Watch no_change separately from skipped
Filter run history on no_change to see “how often was the source unchanged” without conflating it with a generic gate skip.
Leave prior state on a probe error
Return state_updates=None on an error probe so prior state is untouched β a later recovery to the prior output still suppresses correctly.
Keep stored state bounded
State is a small scratchpad, not a database. Store cursors and hashes, not full payloads, so a runaway write can’t grow unbounded.
Prefer a small endpoint over a full page for URL monitors
Each URL probe is capped at a total wall-clock deadline (default 30 s) covering connect and body read, so a slow-drip attacker can no longer stall the scheduler tick. A legitimate page you don’t need still costs a fetch on every tick, though β prefer a small JSON endpoint over a full page and the whole loop stays cheap.
Related
The notepad a monitor reads and writes across runs
Pre-Run GateStateless go/no-go gate β skip when there’s nothing to do
Command ActionModel-free action β deliver a command’s stdout verbatim
Scheduler DeliveryPush scheduled results to Telegram/Discord/Slack/WhatsApp
Bot Intentional SilenceHow unattended silence is enforced
Async SchedulerSchedule agents on intervals, cron, or one-shot timestamps
Context ChainingFan a monitor’s output into a downstream job as bounded context
Scheduled Run PolicySafety gates on what a scheduled run may do