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

# Scheduled Run Policy

> Scope tools, scan prompts, and audit output for unattended scheduled agent runs

`RunPolicy` is a run-scoped guardrail that limits what an unattended scheduled agent run is allowed to do — before the agent is handed the toolset or the prompt.

<Note>
  The same jobs can be seeded from templates and consent-first suggestions — see [Automation Suggestions](/docs/features/automation-suggestions).
</Note>

<Note>
  `praisonai schedule tick` fired from OS cron now delivers without a running gateway, using only `{PLATFORM}_BOT_TOKEN` — see [Deliver from cron / CI](/docs/features/scheduler-delivery#deliver-from-cron-ci-no-gateway-required).
</Note>

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonai_bot.scheduler import ScheduledAgentExecutor  # Tier 2b — bot package
from praisonai.scheduler import RunPolicy                    # Tier 3 — wrapper (safety gate stays here)

agent = Agent(
    name="NewsBot",
    instructions="Summarise today's AI news in 3 bullets.",
)
# executor = ScheduledAgentExecutor(runner=runner, agent_resolver=lambda _: agent, run_policy=RunPolicy())
```

The user schedules an unattended run; RunPolicy scopes tools, scans the prompt, and audits output before delivery.

<Note>
  The pre-tick policy runs **before** the [atomic claim](/docs/docs/features/async-scheduler#multi-process-safety-at-most-once), so a policy rejection avoids taking a lease entirely.
</Note>

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Scheduled Run Policy"
        Job[📅 Scheduled Job] --> Filter[🔧 Toolset Scope]
        Filter --> Scan[🛡️ Prompt Scan]
        Scan -->|ok| Run[🤖 Agent Run]
        Scan -->|blocked| Fail[❌ Fail Closed]
        Run --> Audit[💾 Audit Output]
        Audit --> Deliver[📤 Deliver]
        Fail --> FailSummary[📤 Failure Summary]
    end

    classDef input fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef policy fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef output fill:#10B981,stroke:#7C90A0,color:#fff
    classDef fail fill:#8B0000,stroke:#7C90A0,color:#fff

    class Job input
    class Filter,Scan policy
    class Run agent
    class Audit,Deliver output
    class Fail,FailSummary fail
```

## Defaults (on by default)

The gateway constructs a default `RunPolicy` at boot with **all protections on** — you no longer build one by hand to get the safety net.

| Protection              | Default                   | Effect                                                                  |
| ----------------------- | ------------------------- | ----------------------------------------------------------------------- |
| `deliver_on_failure`    | `True`                    | Blocked or failed runs send a failure summary to the delivery target.   |
| `scan_assembled_prompt` | `True`                    | The assembled prompt is scanned for injection before the model turn.    |
| `denied_toolsets`       | `DEFAULT_DENIED_TOOLSETS` | `cronjob` and `messaging-interactive` are removed from unattended runs. |
| `audit_dir`             | `None`                    | No durable audit unless you set a directory.                            |

The gateway rebuilds the policy from `gateway.yaml` on **every tick**, so a live edit plus hot-reload applies on the next tick — no process restart.

## Configuring via `gateway.yaml`

Override the defaults with an optional `scheduler:` block. Every field is optional; omitted fields keep the fail-closed defaults.

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
scheduler:
  deliver_on_failure: true          # send a failure summary to the job's delivery target
  scan_prompt: true                 # scan the assembled prompt before the turn
  denied_toolsets:                  # tool categories removed from unattended runs
    - cronjob
    - shell
  audit_dir: /var/log/praisonai/scheduler   # durable full-output audit trail
```

| Option               | Type        | Default                   | Description                                                                                                            |
| -------------------- | ----------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `deliver_on_failure` | `bool`      | `true`                    | Send a failure summary to the job's `delivery` target when the run is blocked or the agent raises.                     |
| `scan_prompt`        | `bool`      | `true`                    | Run the injection scan over the assembled prompt (context + user message) before the turn.                             |
| `denied_toolsets`    | `list[str]` | `DEFAULT_DENIED_TOOLSETS` | Tool categories removed from the tool list for the unattended turn. Provide a list to override; omit to keep defaults. |
| `audit_dir`          | `str`       | `null`                    | Directory for the durable full-output audit trail. When set, every scheduled run writes an audit record.               |

<Note>
  The yaml key is `scan_prompt`; it maps to the `scan_assembled_prompt` field on `RunPolicy`.
</Note>

## `gateway.yaml` or Python `RunPolicy`?

Gateway deployments tune the policy in `gateway.yaml`; embedders that run their own executor construct `RunPolicy` in Python.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q{How do you run<br/>scheduled jobs?} -->|Through the gateway| Yaml[Edit scheduler: in gateway.yaml<br/>applies next tick, no restart]
    Q -->|Own ScheduledAgentExecutor<br/>embedded in your app| Py[Construct RunPolicy in Python<br/>pass run_policy= to the executor]

    classDef question fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef yaml fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef py fill:#10B981,stroke:#7C90A0,color:#fff

    class Q question
    class Yaml yaml
    class Py py
```

### Gateway user — tune in `gateway.yaml`

Add a `scheduler:` block, hot-reload, and the next tick uses the new policy. A failing job now delivers a summary to its target:

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
scheduler:
  deliver_on_failure: true
  denied_toolsets:
    - cronjob
    - shell
```

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# hot-reload the running gateway, then trigger a due job once
kill -HUP "$(pgrep -f 'praisonai gateway')"
praisonai schedule tick
```

A blocked or failing run lands as a failure summary on the job's `delivery` target — no silent drop.

### Embedder — construct `RunPolicy` in Python

Import `RunPolicy` and hand it to your own executor. This is the right path outside the gateway:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonaiagents.scheduler import ScheduleRunner, FileScheduleStore
from praisonai_bot.scheduler import ScheduledAgentExecutor
from praisonai.scheduler.run_policy import RunPolicy, DEFAULT_DENIED_TOOLSETS

agent = Agent(name="NewsBot", instructions="Summarise today's AI news in 3 bullets.")

runner = ScheduleRunner(FileScheduleStore())

executor = ScheduledAgentExecutor(
    runner=runner,
    agent_resolver=lambda _id: agent,
    run_policy=RunPolicy(denied_toolsets=DEFAULT_DENIED_TOOLSETS | {"shell"}),
)
```

## Host-app embedding

`praisonai.integration.bridges.schedules_runner` also builds its executor with a default `RunPolicy`, so due jobs in an embedded host app are guarded the same way as the gateway.

<Note>
  If the host-app bridge cannot build an executor, its poll loop does **not** start — due jobs stay genuinely due (never silently consumed) until a later start finds an executor.
</Note>

## Quick Start

<Steps>
  <Step title="Defaults (safe for unattended runs)">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent
    from praisonaiagents.scheduler import ScheduleRunner, FileScheduleStore
    from praisonai_bot.scheduler import ScheduledAgentExecutor
    from praisonai.scheduler import RunPolicy

    agent = Agent(
        name="NewsBot",
        instructions="Summarise today's AI news in 3 bullets.",
    )

    store = FileScheduleStore()
    runner = ScheduleRunner(store)

    executor = ScheduledAgentExecutor(
        runner=runner,
        agent_resolver=lambda _id: agent,
        run_policy=RunPolicy(),
    )
    ```

    <Tip>
      `from praisonai.scheduler.executor import ScheduledAgentExecutor` works as a backward-compatible shim when `praisonai` is installed alongside `praisonai-bot`.
    </Tip>

    `RunPolicy()` with no arguments denies `cronjob` and `messaging-interactive` tools and scans every prompt automatically.
  </Step>

  <Step title="With audit and fail-closed delivery">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent
    from praisonaiagents.scheduler import ScheduleRunner, FileScheduleStore
    from praisonai_bot.scheduler import ScheduledAgentExecutor
    from praisonai.scheduler import RunPolicy

    agent = Agent(
        name="ReportBot",
        instructions="Generate a daily summary report.",
    )

    store = FileScheduleStore()
    runner = ScheduleRunner(store)

    executor = ScheduledAgentExecutor(
        runner=runner,
        agent_resolver=lambda _id: agent,
        run_policy=RunPolicy(
            audit_dir="/var/log/praisonai/runs",
            deliver_on_failure=True,
        ),
    )
    ```

    Every run writes full output to `/var/log/praisonai/runs`. On failure, a compact summary is sent to the delivery target.
  </Step>

  <Step title="Strict allow-list with custom scanner">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent
    from praisonaiagents.scheduler import ScheduleRunner, FileScheduleStore
    from praisonai_bot.scheduler import ScheduledAgentExecutor
    from praisonai.scheduler import RunPolicy

    def my_scanner(prompt):
        return "secret" not in prompt.lower()

    agent = Agent(
        name="SecureBot",
        instructions="Process financial data safely.",
    )

    store = FileScheduleStore()
    runner = ScheduleRunner(store)

    executor = ScheduledAgentExecutor(
        runner=runner,
        agent_resolver=lambda _id: agent,
        run_policy=RunPolicy(
            allowed_toolsets={"search", "summarise"},
            denied_toolsets=set(),
            scanner=my_scanner,
        ),
    )
    ```

    Only `search` and `summarise` tools are available. The custom scanner replaces the built-in heuristic.
  </Step>
</Steps>

***

## How It Works

<Note>
  **Multi-process deployments:** When more than one `BotOS` process shares the same schedule store, each due job is claimed atomically before running and fires at most once across all processes. Both bundled stores support `claim_due` — the default `ConfigYamlScheduleStore` and `FileScheduleStore` — so this holds out of the box. See [BotOS → Multi-Process / HA Deployments](/docs/docs/features/botos#multi-process-ha-deployments).
</Note>

<Note>
  `RunPolicy` is a **safety** gate and stays in the `praisonai` wrapper (`praisonai.scheduler.run_policy`). `ScheduledAgentExecutor` is the **execution** primitive and lives in `praisonai-bot` (`praisonai_bot.scheduler.executor`). With the wrapper installed both are available; running `praisonai-bot` standalone gives you the executor but not `RunPolicy`.
</Note>

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Scheduler
    participant Executor
    participant Policy
    participant Agent
    participant Delivery
    participant Audit

    Scheduler->>Executor: due job
    Executor->>Policy: filter_tools(agent.tools)
    Policy-->>Executor: scoped tools
    Executor->>Policy: scan_prompt(message + skills)
    alt scan ok
        Executor->>Agent: chat(message)
        Agent-->>Executor: result
        Executor->>Audit: persist full output
        Executor->>Delivery: send result
    else scan blocked
        Executor->>Audit: persist error
        Executor->>Delivery: failure summary (if deliver_on_failure)
    end
    Executor->>Executor: restore agent.tools
```

| Step           | What happens                                                                      |
| -------------- | --------------------------------------------------------------------------------- |
| `filter_tools` | Removes denied/disallowed tools from the agent before the run starts              |
| `scan_prompt`  | Checks the assembled prompt for injection patterns                                |
| `chat`         | Agent runs only if the scan passes                                                |
| `persist`      | Full output written to `audit_dir` regardless of delivery outcome                 |
| `deliver`      | Result sent to delivery target; failure summary sent if `deliver_on_failure=True` |
| `restore`      | Agent tools restored to original state after the run                              |

<Note>
  **Model drift fails closed too.** A [pinned job](/docs/features/scheduler-model-pin) whose resolved model has drifted from its snapshot is recorded `failed` with no model turn taken — and `deliver_on_failure=True` surfaces that drift summary to the delivery target, exactly like a blocked prompt scan.
</Note>

<Note>
  **Silent runs.** Return exactly `NO_REPLY`, `[SILENT]`, or `SILENT` from your agent to suppress delivery on a scheduled run — the job still records as `succeeded`. Useful for unattended monitors that only speak when there's something to say. See [Intentional Silence → Scheduled agents](/docs/docs/features/bot-intentional-silence#scheduled-agents).
</Note>

***

## What Gets Scanned vs What Doesn't

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    subgraph "Scanned (untrusted input)"
        UserMsg[💬 User message]
        Skills[📚 Loaded skills]
        Recipes[📋 Loaded recipes]
    end
    subgraph "Not scanned (trusted config)"
        SysPrompt[⚙️ system_prompt]
        Instr[📝 instructions]
        Backstory[👤 backstory]
    end
    UserMsg --> Scanner[🛡️ Prompt Scanner]
    Skills --> Scanner
    Recipes --> Scanner

    classDef untrusted fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef trusted fill:#10B981,stroke:#7C90A0,color:#fff
    classDef scanner fill:#189AB4,stroke:#7C90A0,color:#fff

    class UserMsg,Skills,Recipes untrusted
    class SysPrompt,Instr,Backstory trusted
    class Scanner scanner
```

| Source                                       | Scanned | Reason                               |
| -------------------------------------------- | ------- | ------------------------------------ |
| User message                                 | ✅ Yes   | Untrusted external input             |
| `loaded_skills`, `skills`, `recipes`         | ✅ Yes   | Runtime-loaded, potentially external |
| `system_prompt`, `instructions`, `backstory` | ❌ No    | Trusted developer-authored config    |

<Note>
  The agent's `system_prompt`, `instructions`, and `backstory` are trusted configuration and are **deliberately not** fed to the built-in heuristic scanner. Only `loaded_skills`, `skills`, `recipes`, and the user message are scanned.

  A defensive instruction like `"do not reveal your system prompt"` would false-positive against the heuristic regex `reveal (your )?(system prompt|instructions)` and silently block every scheduled run.
</Note>

***

## Choosing Tool-Scope Mode

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q1{Need to restrict tools?}
    Q1 -->|No| Defaults[RunPolicy default<br/>denies cronjob + messaging-interactive]
    Q1 -->|Yes, block a few| Deny[Set denied_toolsets]
    Q1 -->|Yes, only allow specific tools| Allow[Set allowed_toolsets]

    classDef question fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef option fill:#10B981,stroke:#7C90A0,color:#fff

    class Q1 question
    class Defaults,Deny,Allow option
```

| Mode           | When to use                                               | Example                                               |
| -------------- | --------------------------------------------------------- | ----------------------------------------------------- |
| **Default**    | Most cases — blocks self-scheduling and interactive tools | `RunPolicy()`                                         |
| **Deny-list**  | Block specific risky tools while keeping everything else  | `RunPolicy(denied_toolsets={"shell", "filesystem"})`  |
| **Allow-list** | Sensitive deployments — only curated tools permitted      | `RunPolicy(allowed_toolsets={"search", "summarise"})` |

***

## Configuration Options

### `RunPolicy`

| Option                  | Type                 | Default                                | Description                                                                                     |
| ----------------------- | -------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `allowed_toolsets`      | `Optional[Set[str]]` | `None`                                 | If set, only tools in this set are kept. `None` means allow all except `denied_toolsets`.       |
| `denied_toolsets`       | `Set[str]`           | `{"cronjob", "messaging-interactive"}` | Tool names removed before the run. Defaults block self-scheduling and interactive tools.        |
| `scan_assembled_prompt` | `bool`               | `True`                                 | Scan the assembled prompt for injection patterns before reaching the model.                     |
| `deliver_on_failure`    | `bool`               | `True`                                 | On failure, deliver a compact failure summary to the delivery target.                           |
| `audit_dir`             | `Optional[str]`      | `None`                                 | Directory for durable output audit. `None` disables the audit.                                  |
| `scanner`               | `Optional[Callable]` | `None`                                 | Custom scanner callable. Overrides built-in heuristic. May return `PromptScanResult` or `bool`. |

### `PromptScanResult`

| Field    | Type            | Default | Description                                 |
| -------- | --------------- | ------- | ------------------------------------------- |
| `ok`     | `bool`          | `True`  | `True` if the prompt passed the scan.       |
| `reason` | `Optional[str]` | `None`  | Human-readable reason when `ok` is `False`. |

### `JobResult` Reference

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

<Tip>
  `from praisonai.scheduler.executor import JobResult` also works when the `praisonai` wrapper is installed (backward-compatible shim).
</Tip>

| Field            | Type            | Default       | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| ---------------- | --------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `job`            | `ScheduleJob`   | required      | The `ScheduleJob` that was executed                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `result`         | `Optional[str]` | `None`        | Agent response, or `None` on failure                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `status`         | `str`           | `"succeeded"` | One of `"succeeded"`, `"failed"`, `"skipped"`, `"no_change"`                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `error`          | `Optional[str]` | `None`        | Error message when `status == "failed"` or skip reason when `status == "skipped"`                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `duration`       | `float`         | `0.0`         | Wall-clock seconds for agent execution                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `delivered`      | `bool`          | `False`       | Whether the payload actually reached the target channel. As of [PraisonAI #4198](https://github.com/MervinPraison/PraisonAI/pull/4198) this is the real delivery signal — a gateway routing failure, a missing channel bot, or a `delivery_handler` that returns `False` records `delivered=False` and populates `delivery_error`, even when the send did not raise. On a `deliver_on_failure=True` run whose failure summary reached the channel, `delivered` is `True` — the summary is the delivered payload. |
| `delivery_error` | `Optional[str]` | `None`        | Delivery failure message, separate from execution `error`. For a non-raising failure (gateway routing could not resolve, live handler returned `False`, missing target metadata) this records a synthetic `"delivery to <channel>:<id> did not complete"` string. For a raised exception, records `str(exception)`.                                                                                                                                                                                              |
| `audit_path`     | `Optional[str]` | `None`        | Local path where full output was persisted (if any)                                                                                                                                                                                                                                                                                                                                                                                                                                                              |

<Note>
  `no_change` is a distinct status for [change-detection monitors](/docs/features/scheduler-monitor): a watched source was unchanged, so the model turn was suppressed silently — different from a generic `skipped` gate decision.
</Note>

### Built-in Injection Heuristics

The built-in scanner flags these patterns (case-insensitive):

| Pattern matched                                          |
| -------------------------------------------------------- |
| `ignore (all )?(previous\|prior\|above) instructions`    |
| `disregard (all )?(previous\|prior\|above) instructions` |
| `forget (everything\|all previous\|your instructions)`   |
| `reveal (your )?(system prompt\|instructions)`           |
| `you are now (a )?(different\|new)\b`                    |
| `<\s*/?\s*system\s*>`                                    |

***

## Common Patterns

### Deny-list pattern (default behaviour)

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

policy = RunPolicy()
```

Blocks `cronjob` (prevents self-scheduling) and `messaging-interactive` (no human is present to respond).

### Allow-list for sensitive deployments

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

policy = RunPolicy(
    allowed_toolsets={"search", "summarise"},
)
```

Only `search` and `summarise` are available — everything else is silently removed before the run.

### Custom scanner plugin

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonai.scheduler import RunPolicy, PromptScanResult

def presidio_scanner(prompt: str) -> PromptScanResult:
    # Delegate to Microsoft Presidio or any guardrail library
    result = presidio_analyzer.analyze(text=prompt, language="en")
    if result:
        return PromptScanResult(ok=False, reason=f"PII detected: {result[0].entity_type}")
    return PromptScanResult(ok=True)

policy = RunPolicy(scanner=presidio_scanner)
```

### Extending the default deny-list

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonai.scheduler.run_policy import DEFAULT_DENIED_TOOLSETS, RunPolicy

policy = RunPolicy(
    denied_toolsets=DEFAULT_DENIED_TOOLSETS | {"shell", "filesystem"}
)
```

<Warning>
  Assigning a new set to `denied_toolsets` **replaces** the defaults. Always merge with `DEFAULT_DENIED_TOOLSETS` to keep the built-in protections:

  ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  from praisonai.scheduler.run_policy import DEFAULT_DENIED_TOOLSETS, RunPolicy

  policy = RunPolicy(
      denied_toolsets=DEFAULT_DENIED_TOOLSETS | {"shell", "filesystem"}
  )
  ```
</Warning>

***

## Best Practices

<AccordionGroup>
  <Accordion title="Keep audit_dir on persistent storage">
    `audit_dir` is the only record of a run when delivery fails. Use a path on durable storage (mounted volume, S3-backed FUSE, etc.) — not a temp directory.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    RunPolicy(audit_dir="/mnt/persistent/praisonai/runs")
    ```
  </Accordion>

  <Accordion title="Override denied_toolsets explicitly when extending">
    Adding tools to the deny-list requires merging with `DEFAULT_DENIED_TOOLSETS`. A fresh set assignment silently drops the built-in protections.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonai.scheduler.run_policy import DEFAULT_DENIED_TOOLSETS

    RunPolicy(denied_toolsets=DEFAULT_DENIED_TOOLSETS | {"shell"})
    ```
  </Accordion>

  <Accordion title="Set a bot token so failure summaries reach an operator">
    Failure delivery follows the same rules as success delivery: with a wired `delivery_handler` it goes through the live gateway; without one, it uses the per-platform standalone sender for Telegram / Slack / Discord and records `delivery_error` on the run if neither is available. Set the appropriate `{PLATFORM}_BOT_TOKEN` env var so failure summaries reach an operator even when the gateway is down. See [Out-of-process delivery](/docs/features/scheduler-delivery#out-of-process-delivery).

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    executor = ScheduledAgentExecutor(
        runner=runner,
        agent_resolver=lambda _id: agent,
        delivery_handler=my_notify_function,
        run_policy=RunPolicy(deliver_on_failure=True),
    )
    ```
  </Accordion>

  <Accordion title="Custom scanners must be fast and side-effect-free">
    The scanner runs on every scheduled job, synchronously. Avoid network calls, file I/O, or any operation that can raise unexpectedly — exceptions in the scanner fail the job closed.
  </Accordion>

  <Accordion title="Distinguish result.status from result.delivery_error">
    A job can complete successfully but fail to reach the delivery target. Check both fields:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    result = executor.run_job(job)
    if result.status == "succeeded" and result.delivery_error:
        print(f"Job ran OK but delivery failed: {result.delivery_error}")
        print(f"Full output saved at: {result.audit_path}")

    # As of PraisonAI #4198 the invariant holds both ways:
    #   result.delivered is True   ⇔  the payload reached the target
    #   result.delivered is False  ⇔  it did not, and delivery_error is populated
    ```
  </Accordion>
</AccordionGroup>

***

## Imports: Bot-First and Wrapper Shims

<Tip>
  `praisonai_bot.scheduler` is the canonical import for `ScheduledAgentExecutor` and `JobResult`. The `praisonai.scheduler.*` paths are backward-compatible shims when the wrapper is installed.
</Tip>

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# Canonical (bot-tier)
from praisonai_bot.scheduler import ScheduledAgentExecutor, JobResult
from praisonai_bot.scheduler.condition_gate import ShellConditionGate

# Wrapper shims (require pip install praisonai)
from praisonai.scheduler.executor import ScheduledAgentExecutor
from praisonai.scheduler.condition_gate import ShellConditionGate
```

***

## Related

<CardGroup cols={2}>
  <Card title="Async Agent Scheduler" icon="clock" href="/docs/features/async-agent-scheduler">
    Schedule agents with cost limits and retries
  </Card>

  <Card title="Scheduler Pre-Run Gate" icon="filter" href="/docs/features/scheduler-pre-run-gate">
    Cost-efficiency gate for scheduled ticks
  </Card>

  <Card title="Schedule CLI" icon="terminal" href="/docs/cli/scheduler">
    Command-line scheduling
  </Card>

  <Card title="praisonai-bot SDK" icon="comments" href="/docs/sdk/praisonai-bot/index">
    Full bot-tier SDK reference
  </Card>

  <Card title="Multi-Tenant Scheduler" icon="user-shield" href="/docs/features/scheduler-multi-tenant">
    Isolate each gateway user's jobs with a principal owner key
  </Card>

  <Card title="Scheduler Model Pin" icon="lock" href="/docs/features/scheduler-model-pin">
    Pin a job to a model — drift fails closed and delivers a summary
  </Card>
</CardGroup>
