> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Schedule Tools

> Agent-centric scheduling — let agents add, list, pause, resume, update, and remove scheduled jobs

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonaiagents.tools import (
    schedule_add, schedule_list, schedule_remove,
    schedule_pause, schedule_resume, schedule_update,
)

agent = Agent(
    name="Reporter",
    instructions="Manage the user's recurring reports — add, list, pause, resume, update, and remove.",
    tools=[
        schedule_add, schedule_list, schedule_remove,
        schedule_pause, schedule_resume, schedule_update,
    ],
)
agent.start("Every weekday at 8am send me a briefing, and pause it on weekends")
```

The user describes a recurring reminder; the agent registers a schedule job via schedule tools.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    U[Input] --> A[Agent]
    A --> T[Tool]
    T --> O[Output]

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef tool fill:#189AB4,stroke:#7C90A0,color:#fff

    class A agent
    class U,O tool
    class T tool
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Agent
    participant Feature as Schedule Tools

    User->>Agent: Request
    Agent->>Feature: Process request
    Feature-->>Agent: Result
    Agent-->>User: Response
```

<Note>
  **Built-in** — no extra dependencies required. Schedule tools are included in the core `praisonaiagents` package.
</Note>

Schedule tools let your agents self-schedule reminders, recurring tasks, and one-shot jobs — all via simple tool calls. Optionally gate each tick with a cheap shell check via `pre_run` so expensive model turns only happen when there's real work to do. No changes to the `Agent` class are needed.

## Quick Start

<Steps>
  <Step title="Simple Usage">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent
    from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove

    agent = Agent(
        name="assistant",
        instructions="You can set reminders and schedules for the user.",
        tools=[schedule_add, schedule_list, schedule_remove],
    )

    agent.start("Remind me to check email every morning at 7am")
    ```
  </Step>

  <Step title="With Configuration">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents.tools import schedule_list

    print(schedule_list())
    ```
  </Step>
</Steps>

The agent will call `schedule_add` with the appropriate schedule expression, and the job will be persisted to disk.

## Available Tools

### schedule\_add

Add a new scheduled job.

| Parameter     | Type   | Required | Description                                                                                                                                                                                                                       |
| ------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`        | `str`  | Yes      | Human-readable name (e.g. `"morning-email-check"`)                                                                                                                                                                                |
| `schedule`    | `str`  | Yes      | When to run (see [Schedule Expressions](#schedule-expressions))                                                                                                                                                                   |
| `message`     | `str`  | No       | Prompt or reminder text to deliver when triggered                                                                                                                                                                                 |
| `deliver`     | `str`  | No       | Delivery token: `origin`, `telegram`, `all`, or `platform:chat_id[:thread_id]`                                                                                                                                                    |
| `tz`          | `str`  | No       | IANA timezone applied to naive `at:` timestamps and `cron:` expressions. See [Timezones](#timezones-for-at-and-cron) for the resolution order.                                                                                    |
| `continuable` | `bool` | No       | Default `True`. Seed a resumable session on delivery so a reply in the same chat resumes the job's conversation with the brief in context. Set `False` for fire-and-forget notifications.                                         |
| `once`        | `bool` | No       | Default `False`. When `True`, the job is a one-shot — it is removed automatically after its single successful fire (maps to `delete_after_run`). Ideal for `at:` / `in ...` reminders so a spent job does not linger in listings. |

**Returns:** Confirmation string with the job id.

### schedule\_list

List all scheduled jobs. Takes no parameters.

**Returns:** Formatted string listing every job with id, name, schedule, status, and message.

### schedule\_remove

Remove a scheduled job by name.

| Parameter | Type  | Required | Description                    |
| --------- | ----- | -------- | ------------------------------ |
| `name`    | `str` | Yes      | Name of the schedule to remove |

**Returns:** Confirmation or not-found message.

### schedule\_pause

Pause a schedule by name. Sets `enabled=False` so every due-check skips the job without deleting it — the counterpart to `schedule_resume`.

| Parameter   | Type  | Required | Description                                                                         |
| ----------- | ----- | -------- | ----------------------------------------------------------------------------------- |
| `name`      | `str` | Yes      | Name of the schedule to pause                                                       |
| `principal` | `str` | No       | Resolved caller identity; defaults from the session context on a multi-user gateway |

**Returns:** Confirmation or not-found message.

### schedule\_resume

Resume a paused schedule by name. Sets `enabled=True` so the job fires again on its next due tick.

| Parameter   | Type  | Required | Description                                                                         |
| ----------- | ----- | -------- | ----------------------------------------------------------------------------------- |
| `name`      | `str` | Yes      | Name of the schedule to resume                                                      |
| `principal` | `str` | No       | Resolved caller identity; defaults from the session context on a multi-user gateway |

**Returns:** Confirmation or not-found message.

### schedule\_update

Update a schedule's cadence and/or message. Only the fields you pass change; empty values leave the current value untouched. Changing `schedule` clears `last_run_at` so the new cadence runs fresh.

| Parameter   | Type  | Required | Description                                                                         |
| ----------- | ----- | -------- | ----------------------------------------------------------------------------------- |
| `name`      | `str` | Yes      | Name of the schedule to update                                                      |
| `schedule`  | `str` | No       | New schedule expression. Empty leaves the cadence unchanged                         |
| `message`   | `str` | No       | New prompt / reminder text. Empty leaves it unchanged                               |
| `tz`        | `str` | No       | IANA timezone applied when re-parsing `schedule`                                    |
| `principal` | `str` | No       | Resolved caller identity; defaults from the session context on a multi-user gateway |

**Returns:** Confirmation or not-found / error message.

The CLI exposes the same surface — see [`praisonai schedule pause / resume / update`](/docs/cli/schedule#managing-store-backed-schedules).

## Schedule Expressions

Pick the format that matches how the job should recur.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TD
    Q[When should this run?]
    Q -->|Roughly every hour / day / week| K["Keyword<br/>hourly / daily / weekly"]
    Q -->|Every N minutes/hours/seconds| I["Interval<br/>*/30m, */6h, */10s"]
    Q -->|Precise clock times or weekdays| C["Cron<br/>cron:0 9 * * 1-5"]
    Q -->|Just once at a specific time| A["One-shot<br/>at:2026-03-01T09:00:00"]
    Q -->|Just once, from now| R["Relative<br/>in 20 minutes"]

    classDef q fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef opt fill:#189AB4,stroke:#7C90A0,color:#fff
    class Q q
    class K,I,C,A,R opt
```

| Format   | Example                           | Description                                                                                                                      |
| -------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Keyword  | `"hourly"`, `"daily"`, `"weekly"` | Predefined intervals (3600 / 86400 / 604800 seconds)                                                                             |
| Interval | `"*/30m"`, `"*/6h"`, `"*/10s"`    | Custom interval (minutes, hours, seconds)                                                                                        |
| Cron     | `"cron:0 7 * * *"`                | 5-field cron expression                                                                                                          |
| One-shot | `"at:2026-03-01T09:00:00"`        | ISO 8601 timestamp — a naive value is stamped with the resolved zone at parse time (see [Timezones](#timezones-for-at-and-cron)) |
| Relative | `"in 20 minutes"`                 | Relative to now (stored as an aware UTC instant)                                                                                 |
| Seconds  | `"3600"`                          | Raw seconds                                                                                                                      |

<Note>
  A naive `at:` timestamp (no `Z`, no `+HH:MM`) is a **wall-clock** reading. As of [PraisonAI #4732](https://github.com/MervinPraison/PraisonAI/pull/4732) the parser stamps it with the resolved zone at parse time, so the stored value names an unambiguous instant. See [Timezones for `at:` and `cron:`](#timezones-for-at-and-cron).
</Note>

## Examples

### Recurring Schedule

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove

agent = Agent(
    name="news-bot",
    instructions="""You help users stay informed.
    When asked, create schedules for news summaries.
    Use schedule_add with cron expressions for precise timing.""",
    tools=[schedule_add, schedule_list, schedule_remove],
)

# Agent will create: schedule_add("morning-news", "cron:0 7 * * *", "Summarize AI news")
agent.start("Send me an AI news summary every morning at 7am")
```

### One-Shot Reminder

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# Agent will create: schedule_add("meeting-prep", "in 20 minutes", "Prepare for standup")
agent.start("Remind me in 20 minutes to prepare for standup")
```

### Fire-and-forget vs. continuable

A delivered brief is continuable by default — a reply in the same chat resumes the job's conversation. For pure alerts, set `continuable=False` so a reply stays a fresh turn.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.tools import schedule_add

# Continuable (default) — reply resumes the conversation with the brief in context
schedule_add(
    name="morning-brief",
    schedule="cron:0 7 * * *",
    message="Summarise the morning news",
    deliver="telegram:123456",
)

# Fire-and-forget — reply stays a brand-new turn
schedule_add(
    name="backup-alert",
    schedule="cron:0 3 * * *",
    message="Backup ok",
    deliver="slack:C012345",
    continuable=False,
)
```

See [Scheduler Delivery → Continuable Delivery](/docs/docs/features/scheduler-delivery#continuable-delivery) for the full seed contract.

### List and Manage

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# Agent will call schedule_list() and schedule_remove("old-task")
agent.start("Show me my schedules and remove 'old-task'")
```

### One-Shot Job

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.tools import schedule_add

schedule_add(
    name="deploy-ping",
    schedule="in 10 minutes",
    message="Kick off the deploy check-in",
    deliver="slack:C0123456",
    once=True,   # auto-remove after firing so it does not linger
)
```

### Using String Tool Names

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
agent = Agent(
    name="scheduler",
    tools=[
        "schedule_add", "schedule_list", "schedule_remove",
        "schedule_pause", "schedule_resume", "schedule_update",
    ],
)
```

## Timezones for `at:` and `cron:`

Which wall-clock time a schedule uses depends on the timezone it resolves to.

For a naive `at:` ISO timestamp (no `Z`, no `+HH:MM`) and the natural-language clock forms (`at 9am`, `at 17:00`), the parser resolves the zone in order:

1. The `tz` argument passed to the schedule (per-schedule).
2. The instance default (the `scheduler.timezone` key in `config.yaml`).
3. The `PRAISONAI_SCHEDULE_TIMEZONE` environment variable (process-wide default).
4. The machine's local zone.

The offset attached is the one **in force on the target date**, so a summer date gets the DST offset and a winter date gets the standard offset. An `at:` string that already carries an offset (`+05:30`, `Z`, `+00:00`) is used verbatim — the offset in the string always wins.

`cron:` expressions follow the same 1–3 precedence but fall back to **UTC** at step 4, unchanged from previous releases. Only the naive-`at:` path defaults to the local zone.

### The stored value is aware

As of [PraisonAI #4732](https://github.com/MervinPraison/PraisonAI/pull/4732), `parse_schedule` returns a `Schedule` whose `at` is already an aware ISO string. A job authored on one machine and later polled by a runner in a different zone fires at the same wall-clock time the person originally typed — the guess `is_due()` used to make at fire time is gone.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.scheduler.parser import parse_schedule

# In Europe/London, in the winter, this stores an offset of +00:00
parse_schedule("at:2026-12-01T09:00:00").at   # -> "2026-12-01T09:00:00+00:00"

# In Europe/London, in the summer, this stores an offset of +01:00
parse_schedule("at:2026-07-01T09:00:00").at   # -> "2026-07-01T09:00:00+01:00"

# An explicit tz overrides the system zone
parse_schedule("at:2026-07-01T09:00:00", tz="America/New_York").at
# -> "2026-07-01T09:00:00-04:00"

# An explicit offset in the string is kept verbatim
parse_schedule("at:2026-07-01T09:00:00+02:00").at
# -> "2026-07-01T09:00:00+02:00"
```

### DST is handled per target date

`localize_wall_clock` attaches the offset in force **on the target date**, not the offset in force right now. In Europe/London, `at:2026-07-01T09:00:00` stamps `+01:00` (BST) even if you parse it in January, and `at:2026-12-01T09:00:00` stamps `+00:00` (GMT) even if you parse it in July. You do not need to hand-compute offsets in the ISO string.

### `PRAISONAI_SCHEDULE_TIMEZONE` fails loudly on bad names

An unknown IANA zone in `PRAISONAI_SCHEDULE_TIMEZONE` (or in the `tz=` argument) now raises `ValueError` from `parse_schedule` when it hits an `at:` or clock form, instead of being accepted silently and never firing. A `Schedule(kind="at", at=<naive iso>)` built directly hits the same check when `is_due()` reads it.

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# Rejected at add time instead of firing never
PRAISONAI_SCHEDULE_TIMEZONE=Not/A/Zone \
  praisonai schedule add "x" -s "at:2026-07-01T09:00:00"
# -> ValueError: Unknown IANA timezone: 'Not/A/Zone'
```

### Legacy stored jobs

<Note>
  Jobs stored **before #4705** were read as UTC. Since #4705 they are read in the local zone; #4732 does not re-move them, but the DST correction means a legacy naive `at` on the other DST half of the year is now read at the correct wall-clock time instead of an hour off. Jobs stored **after #4732** carry the offset in the ISO string and are portable across machines. `in N minutes` was already stored as an aware UTC instant and is unchanged. Hand-written `config.yaml` `at:` strings that omit an offset are still supported — they fall through the same `localize_wall_clock` path in `is_due()`, using the schedule's `tz`, else the store default, else `PRAISONAI_SCHEDULE_TIMEZONE`, else the runner's local zone.
</Note>

## Agent self-management flow

The agent creates, pauses, updates, resumes, and inspects a schedule entirely through tool calls in a single conversation.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Agent
    participant Tool as Schedule Tools
    participant Store

    User->>Agent: Add a morning brief at 8am on weekdays
    Agent->>Tool: schedule_add("morning-brief", "cron:0 8 * * 1-5", ...)
    Tool->>Store: persist job
    Store-->>Agent: added ✓

    User->>Agent: Pause it for this week
    Agent->>Tool: schedule_pause("morning-brief")
    Tool->>Store: enabled=False
    Store-->>Agent: paused ✓

    User->>Agent: Actually change it to 9am
    Agent->>Tool: schedule_update("morning-brief", schedule="cron:0 9 * * 1-5")
    Tool->>Store: new cadence, last_run_at cleared
    Store-->>Agent: updated ✓

    User->>Agent: Turn it back on
    Agent->>Tool: schedule_resume("morning-brief")
    Tool->>Store: enabled=True
    Store-->>Agent: resumed ✓

    User->>Agent: How did it run last week?
    Agent->>Tool: schedule_list()
    Tool->>Store: read jobs
    Store-->>Agent: status + history

    Note over Agent,Store: Every tool shares the same _get_store() singleton
```

<Note>
  `schedule_pause`, `schedule_resume`, and `schedule_update` use the same `_get_store()` singleton as `schedule_add` / `schedule_list` / `schedule_remove`, so a job authored by any tool is fully manageable by every tool.
</Note>

## Storage

Jobs are persisted to `~/.praisonai/config.yaml` under the `schedules` key by default via `ConfigYamlScheduleStore`. The store is:

* **Thread-safe** for multi-agent scenarios
* **Atomic writes** (tmp + rename) to prevent corruption
* **Auto-created** on first use
* **Auto-migrates** legacy `jobs.json` data on first load

<Note>
  This exact store instance is shared by the gateway scheduler tick and the wrapper's schedules bridge. Jobs authored by an agent tool are polled by every runtime — you no longer see "scheduled ✓" silently drop.
</Note>

### Shared Default Store

Every runtime shares one process-wide store via `get_default_store()` so a job authored by an agent is claimed by the gateway ticker, not silently dropped.

| Function                   | Signature                                | What it does                                                                                                                                           |
| -------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `get_default_store()`      | `() -> ScheduleStoreProtocol`            | Lazy-inits and returns the canonical `ConfigYamlScheduleStore` (migrating any legacy `jobs.json`) on first call; later calls return the same instance. |
| `set_default_store(store)` | `(store: ScheduleStoreProtocol) -> None` | Overrides the process-wide store. Call once at startup before agent tools, the gateway tick, or the host bridge resolve the store.                     |

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.scheduler import get_default_store, set_default_store

# read the current default
store = get_default_store()

# swap it for a custom backend at process start
class MyStore: ...
set_default_store(MyStore())
```

`praisonaiagents.tools.schedule_tools.set_store(my_store)` continues to work and now also repoints the canonical default under the hood.

### Custom Store (ScheduleStoreProtocol)

Swap the default file store for any backend that implements `ScheduleStoreProtocol`:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.scheduler import ScheduleStoreProtocol

class MyDatabaseStore:
    """Any object with these methods works."""
    def add(self, job): ...
    def get(self, job_id) -> Optional[Any]: ...
    def list(self, agent_id=None) -> list: ...
    def update(self, job): ...
    def remove(self, job_id) -> bool: ...
    def get_by_name(self, name) -> Optional[Any]: ...
    def remove_by_name(self, name) -> bool: ...

assert isinstance(MyDatabaseStore(), ScheduleStoreProtocol)  # ✅
```

Inject it at startup so all agent `schedule_add/list/remove` calls use your store:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.tools.schedule_tools import set_store

my_store = MyDatabaseStore()
set_store(my_store)  # All schedule tools now use this store
```

`set_store()` now also repoints the process-wide `scheduler.get_default_store()` so the gateway tick and host bridge pick up the same backend. This is best-effort and logs a warning if the repoint fails; the tool store you passed is always authoritative for the agent tools.

<Info>
  PraisonAIUI and BotOS use the same `config.yaml` store. You can also call `set_store()` to inject any custom backend.
</Info>

### Custom Provider (SchedulerProviderProtocol)

Swap the default in-process poll thread for any backend that decides *when* to fire:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.scheduler import SchedulerProviderProtocol, ScheduleLoop

# The built-in in-process loop is the default provider (also exported as
# InProcessScheduleProvider). Any object with start(on_due, store=...) + stop()
# satisfies the protocol — e.g. a cloud-scheduler webhook, systemd timer, or
# APScheduler cron trigger that calls engine.fire_due() on its own schedule.
engine = ScheduleLoop(on_trigger=handle_job)

# Serverless — fire when the external system pings us, no always-on thread:
def on_webhook():
    engine.fire_due()
```

See [Scheduler Providers](/docs/features/scheduler-providers) for full patterns.

## Schedule Runner

The `ScheduleRunner` checks which jobs are due for execution:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.scheduler import ScheduleRunner, get_default_store

store = get_default_store()
runner = ScheduleRunner(store=store)

# Get jobs that are due right now
due_jobs = runner.get_due_jobs()

for job in due_jobs:
    print(f"Due: {job.name} — {job.message}")
    runner.mark_run(job)  # Updates last_run_at
```

Constructing `ConfigYamlScheduleStore()` directly still works — the process-wide `get_default_store()` returns the same class by default and is the recommended way to share one instance with the gateway tick and host bridge.

## Hook Events

Schedule lifecycle events are available via the hook system:

| Event              | When                      |
| ------------------ | ------------------------- |
| `SCHEDULE_ADD`     | A new schedule is created |
| `SCHEDULE_REMOVE`  | A schedule is deleted     |
| `SCHEDULE_TRIGGER` | A scheduled job fires     |

## Execution History

Every scheduled job execution is logged as a `RunRecord` for auditing:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.scheduler import get_default_store

store = get_default_store()

# Get last 10 executions for a job
history = store.get_history("job-abc123", limit=10)

for run in history:
    print(f"{run.job_name}: {run.status} ({run.duration:.1f}s)")
    if run.error:
        print(f"  Error: {run.error}")
```

| Field       | Type    | Description                                                                            |
| ----------- | ------- | -------------------------------------------------------------------------------------- |
| `job_id`    | `str`   | Job that was executed                                                                  |
| `job_name`  | `str`   | Human-readable job name                                                                |
| `status`    | `str`   | `"succeeded"`, `"failed"`, or `"skipped"` (when a `pre_run` gate returned `run=False`) |
| `result`    | `str`   | Agent output (truncated)                                                               |
| `error`     | `str`   | Error message if failed                                                                |
| `duration`  | `float` | Execution time in seconds                                                              |
| `delivered` | `bool`  | Whether result was delivered to channel                                                |
| `timestamp` | `float` | Epoch timestamp                                                                        |

## Executing Scheduled Jobs

Schedule tools **create and persist** jobs, but to actually **execute** them when they're due, use `ScheduleLoop`:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.scheduler import ScheduleLoop

def handle_job(job):
    print(f"🔔 {job.name}: {job.message}")

loop = ScheduleLoop(on_trigger=handle_job, tick_seconds=30)
loop.start()
```

<Info>
  See [Background Tasks — ScheduleLoop](/docs/docs/features/background-tasks#scheduleloop) for the full API and combined examples with `BackgroundRunner`.
</Info>

<Tip>
  `ScheduleLoop` is the default provider. For event-driven firing (cloud webhook, systemd timer, K8s CronJob) see [Scheduler Providers](/docs/features/scheduler-providers).
</Tip>

## Pre-Run Condition Gate

Gate a scheduled tick on a cheap shell check so no model tokens are spent when there's nothing to do.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q{Does the tick always have work?}
    Q -->|Yes — always run| Plain[No pre_run<br/>Runs unconditionally]
    Q -->|No — depends on external state| Gate[Use pre_run shell command]
    Gate --> Ex1[Exit 0, empty stdout →<br/>runs, no extra context]
    Gate --> Ex2[Exit 0, non-empty stdout →<br/>runs, stdout appended as context]
    Gate --> Ex3[Non-zero exit →<br/>skipped, 0 tokens spent]
    Gate --> Ex4[Timeout > 30s →<br/>skipped, process group killed]

    classDef q fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef opt fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    class Q q
    class Plain,Gate opt
    class Ex1,Ex2,Ex3,Ex4 result
```

<Steps>
  <Step title="Add pre_run to a schedule in bot.yaml">
    ```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    # bot.yaml
    schedules:
      - name: new-issue-triage
        schedule: "*/5m"
        message: "Triage any new issues from the queue."
        pre_run: "gh issue list --state open --json number,title --search 'created:>1h ago'"
    ```
  </Step>

  <Step title="Every tick, PraisonAI evaluates pre_run before spending tokens">
    | Exit code         | stdout    | Outcome                                                             |
    | ----------------- | --------- | ------------------------------------------------------------------- |
    | `0`               | empty     | Job runs with original message; no context added                    |
    | `0`               | non-empty | Job runs; stdout appended as context (capped at 8 000 chars)        |
    | non-zero          | —         | Job **skipped**; truncated stderr in `reason` (max 500 chars)       |
    | timed out (>30 s) | —         | Job skipped; process group killed; reason: `pre-run gate timed out` |
  </Step>
</Steps>

<Note>
  `pre_run` is a **cost gate** (decides *whether* to run). It is not a **safety gate** (`RunPolicy`, which decides *what* a run may do). Use both when you need both.
</Note>

### Real-World Examples

**Only triage when new issues exist:**

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
pre_run: "gh issue list --state open --search 'created:>10m ago' --json title"
```

**Only summarise inbox when there's unread mail:**

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
pre_run: "test -s $HOME/.mail/inbox/unread"
```

**Guard against off-hours runs (Monday–Friday, 09:00–18:00):**

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
pre_run: "test $(date +%u) -lt 6 && test $(date +%H) -ge 9 && test $(date +%H) -lt 18"
```

### Custom Condition Gate

Any object implementing `JobConditionProtocol` can replace the default shell gate — a Python callable, an MCP probe, a database check.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.scheduler.protocols import GateResult, JobConditionProtocol
from praisonai.scheduler.executor import ScheduledAgentExecutor

class DatabaseGate:
    def should_run(self, job) -> GateResult:
        rows = pending_rows()
        if rows == 0:
            return GateResult(run=False, reason="no pending rows")
        return GateResult(run=True, context=f"{rows} rows waiting")

executor = ScheduledAgentExecutor(
    runner=runner,
    agent_resolver=lambda agent_id: gateway.get_agent(agent_id),
    condition_resolver=lambda job: DatabaseGate(),
)
```

Pass `condition_resolver=False` to disable gating entirely. The default resolver automatically activates `ShellConditionGate` for any job that has a `pre_run` value.

## BotOS Integration

When using BotOS (multi-platform bot orchestrator), scheduled jobs execute **automatically** — no `ScheduleLoop` needed. BotOS runs its own 30-second schedule tick alongside all bots:

* Agents create jobs via `schedule_add` during conversations
* BotOS detects due jobs every 30 seconds
* The originating agent processes the job message
* Results are delivered back to the originating platform (Telegram, Discord, etc.)

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# bot.yaml — schedules work out of the box
agent:
  name: assistant
  tools:
    - schedule_add
    - schedule_list
    - schedule_remove

channels:
  telegram:
    token: ${TELEGRAM_BOT_TOKEN}
```

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
praisonai bot start --config bot.yaml
# Agent can now self-schedule and BotOS executes + delivers results
```

## Architecture

Schedule tools follow PraisonAI's core principles:

* **Agent-centric** — tools, not Agent parameters
* **Lazy-loaded** — zero import cost until used
* **Protocol-driven** — `ScheduleStoreProtocol` makes stores swappable
* **No Agent bloat** — the `Agent` class is unchanged
* **Thread-safe** — safe for multi-agent workflows
* **Pluggable** — `set_store()` lets any backend replace the default file store

## See Also

<CardGroup cols={2}>
  <Card title="Schedule CLI" icon="terminal" href="/docs/cli/schedule">
    CLI equivalents: add, list, pause, resume, update, remove
  </Card>

  <Card title="Background Tasks" icon="clock" href="/docs/features/background-tasks">
    Sync wrappers, ScheduleLoop, and combined recipes
  </Card>

  <Card title="Scheduler CLI" icon="terminal" href="/docs/cli/scheduler">
    24/7 autonomous agent scheduling via CLI
  </Card>
</CardGroup>

## Best Practices

<AccordionGroup>
  <Accordion title="Use cron expressions for precise schedules">
    Cron expressions give exact control over scheduling - prefer them for production use.
  </Accordion>

  <Accordion title="Log scheduled job execution">
    Add logging to scheduled agent tasks so you can verify they ran and diagnose failures.
  </Accordion>

  <Accordion title="Test with short intervals first">
    Use 1-minute intervals during testing, then switch to production schedules before deployment.
  </Accordion>

  <Accordion title="Handle job failures gracefully">
    Scheduled jobs should catch exceptions and report errors rather than silently failing.
  </Accordion>

  <Accordion title="Use pre_run to avoid paying for empty ticks">
    If a schedule only has work when some external state changes (new emails, new PRs, a queue with pending rows), put the cheap check in `pre_run`. Model tokens are spent only for ticks that actually have work to do.
  </Accordion>
</AccordionGroup>

## Related

<CardGroup cols={2}>
  <Card title="Custom Tools" icon="wrench" href="/docs/tools/custom">
    Build your own agent tools
  </Card>

  <Card title="Tools Overview" icon="toolbox" href="/docs/tools/tools">
    Browse PraisonAI tool documentation
  </Card>
</CardGroup>
