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.

Diagram

Authors

SCHEDULE_ADD

SCHEDULE_TRIGGER

SCHEDULE_REMOVE

🤖 schedule_add tool

⌨️ praisonai schedule

⚙️ config.yaml reconciler

🗄️ Schedule Store

✅ Hook fires

✅ Hook fires

🗄️ Spent-job retire
on completion

✅ Hook fires

Quick Start

  1. 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()
  2. Give an Agent the tool

    Hand the agent the schedule_add tool — 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).

Diagram
Your HookHookRunnerStoreAuthor (tool / CLI / reconciler)Your HookHookRunnerStoreAuthor (tool / CLI / reconciler)save failed → raise OSError, no hookalt[no hooks registered][hooks registered]add(job)persist + release lockshas_hooks(SCHEDULE_ADD)?return (zero cost)emit (fire-and-forget in async loop)SCHEDULE_ADD payloadreturn

What Fires (and What Doesn’t)

Events fire only on a real change.

SituationEmits SCHEDULE_ADDEmits 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):

FieldTypeDefaultDescription
job_namestr""Human-friendly job name.
job_idstr""Stable id assigned by the store.
schedulestr""Rendered as "every 3600s", "cron:0 8 1-5", or "at:2026-09-08T10:00:00".
messagestr""The scheduled prompt. Truncated to 500 chars in to_dict().
agent_idstr""The job’s agent_id (or "").
principalstr""Tenant / principal string for multi-tenant scoping.
enabledboolTrueWhether the job is enabled at add time.

ScheduleRemoveInput (fires on SCHEDULE_REMOVE):

FieldTypeDefaultDescription
job_namestr""Name of the deleted job.
job_idstr""Id of the deleted job.
schedulestr""Rendered same as above.
principalstr""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.

python
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.

python
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.

python
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.


Related