Schedule Lifecycle Hooks
Observe when scheduled jobs are added, removed, and triggered — from any source
Schedule lifecycle hooks fire when a scheduled job is persisted, deleted, or triggered — so your audit log or metrics endpoint sees the whole life of every job, no matter who created it.
Quick Start
- Register a hook
Register one hook on the default registry for
SCHEDULE_ADD.python from praisonaiagents.hooks import HookEvent, HookResult, get_default_registry registry = get_default_registry() @registry.on(HookEvent.SCHEDULE_ADD) def on_add(event_data): print(f"Added: {event_data.job_name} → {event_data.schedule}") print(f"Job ID: {event_data.job_id}, enabled: {event_data.enabled}") return HookResult.allow() - Give an Agent the tool
Hand the agent the
schedule_addtool — the store fires your hook when the agent persists a job.python from praisonaiagents import Agent from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove agent = Agent( name="Scheduler", instructions="Schedule reminders the user asks for.", tools=[schedule_add, schedule_list, schedule_remove], ) agent.start("Remind me to stretch every 5 minutes")
How It Works
SCHEDULE_ADD and SCHEDULE_REMOVE are emitted by the schedule store — the one chokepoint every add/remove path funnels through — after its locks release.
Hooks fire only on a successful persist. If FileScheduleStore.add(), remove(), or remove_by_name() fails to save, the store raises OSError, reloads memory from disk, and emits nothing — a hook never sees a job that doesn’t exist on disk (PraisonAI PR #5901).
What Fires (and What Doesn’t)
Events fire only on a real change.
| Situation | Emits SCHEDULE_ADD | Emits SCHEDULE_REMOVE |
|---|---|---|
store.add(job) succeeds | ✅ | — |
store.add(job) raises (duplicate id/name) | ❌ | — |
store.remove(job_id) finds the job | — | ✅ |
store.remove(job_id) finds nothing | — | ❌ |
store.remove_by_name(name) cross-tenant mismatch | — | ❌ |
mark_run() completes a spent run (one-shot delete_after_run or max_runs budget met) | — | ✅ (once, on completion) |
mark_run() records a failed spent run | — | ❌ (job kept, disabled) |
claim_due() claims a spent job | — | ❌ (kept leased until the run completes) |
claim_due() finds a spent job whose retry was also abandoned (crash), or whose until deadline passed | — | ✅ (once) |
claim_due() runs a recurring job that is not yet spent | — | ❌ |
_save() fails and rolls back the delete | — | ❌ |
Retirement via any should_retire() path (one-shot, max_runs, or until) emits the same SCHEDULE_REMOVE hook as a manual schedule_remove — the store is the single chokepoint every deletion funnels through. For a normal spent run that removal happens in ScheduleRunner.mark_run() after the run completes; claim_due() only removes a spent job when its crash retry was abandoned too or its until deadline has passed. See Crash Recovery.
Payload Reference
ScheduleAddInput (fires on SCHEDULE_ADD):
| Field | Type | Default | Description |
|---|---|---|---|
job_name | str | "" | Human-friendly job name. |
job_id | str | "" | Stable id assigned by the store. |
schedule | str | "" | Rendered as "every 3600s", "cron:0 8 1-5", or "at:2026-09-08T10:00:00". |
message | str | "" | The scheduled prompt. Truncated to 500 chars in to_dict(). |
agent_id | str | "" | The job’s agent_id (or ""). |
principal | str | "" | Tenant / principal string for multi-tenant scoping. |
enabled | bool | True | Whether the job is enabled at add time. |
ScheduleRemoveInput (fires on SCHEDULE_REMOVE):
| Field | Type | Default | Description |
|---|---|---|---|
job_name | str | "" | Name of the deleted job. |
job_id | str | "" | Id of the deleted job. |
schedule | str | "" | Rendered same as above. |
principal | str | "" | Tenant / principal of the deleted job. |
Both inherit session_id, cwd, event_name, timestamp, and agent_name from HookInput (agent_name is the job’s agent_id if set, else "scheduler").
Common Patterns
Audit log
Append every add and remove to a JSONL file.
import json
from praisonaiagents.hooks import HookEvent, HookResult, get_default_registry
registry = get_default_registry()
def audit(event_data):
with open("schedule_audit.jsonl", "a") as f:
f.write(json.dumps({
"event": event_data.event_name,
"session_id": event_data.session_id,
"principal": event_data.principal,
"job_id": event_data.job_id,
"schedule": event_data.schedule,
}) + "\n")
return HookResult.allow()
registry.on(HookEvent.SCHEDULE_ADD)(audit)
registry.on(HookEvent.SCHEDULE_REMOVE)(audit)Per-tenant quota
Count active schedules per principal.
from collections import Counter
from praisonaiagents.hooks import HookEvent, HookResult, get_default_registry
registry = get_default_registry()
active = Counter()
@registry.on(HookEvent.SCHEDULE_ADD)
def inc(event_data):
active[event_data.principal] += 1
return HookResult.allow()
@registry.on(HookEvent.SCHEDULE_REMOVE)
def dec(event_data):
active[event_data.principal] -= 1
return HookResult.allow()One-shot metric
Push a metric when a spent one-shot is auto-removed.
from praisonaiagents.hooks import HookEvent, HookResult, get_default_registry
registry = get_default_registry()
@registry.on(HookEvent.SCHEDULE_REMOVE)
def one_shot_metric(event_data):
if event_data.schedule.startswith("at:"):
print(f"scheduler.one_shot_completed job_id={event_data.job_id}")
return HookResult.allow()Emission Discipline
Inside a running asyncio loop, emission is fire-and-forget; asyncio.run(...) around your code works because the emitter handles both loop states.
Best Practices
Keep hooks lightweight
Hooks may run inside an async event loop. Avoid blocking I/O; offload heavy work to a queue.
Filter early on principal
For multi-tenant setups, check event_data.principal at the top of the hook and return HookResult.allow() immediately when it doesn’t match.
Don't gate the mutation from a hook
These events are observability-only — the store proceeds regardless of HookResult.deny(...). To control who can create schedules, gate the callers: guardrails on the tool, auth on the CLI.
Use job_id as the durable key
Use event_data.job_id, not event_data.job_name, as the stable key — names can be reused.