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

# Gateway Schedules

> Declarative recurring agent → channel deliveries in gateway.yaml, with matching add/list/remove CLI

Run any agent on a cron / interval / one-shot and post the reply to a channel — from a few lines of YAML, with no Python.

> [!NOTE]
>
> See also: [Gateway Inbound Hooks](https://praison.ai/docs/features/gateway-inbound-hooks.md) — the inbound HTTP trigger counterpart to this outbound scheduled delivery surface.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    TR[⏰ Trigger<br/>cron / every / at] --> SC

    subgraph "Gateway"
        SC[📅 Scheduler] --> AG[🤖 Agent Run]
    end

    AG --> CB[📱 Channel]
    CB --> US[👤 User]

    classDef trigger fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef gw fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef agent fill:#10B981,stroke:#7C90A0,color:#fff
    classDef delivery fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef user fill:#6366F1,stroke:#7C90A0,color:#fff

    class TR trigger
    class SC gw
    class AG agent
    class CB delivery
    class US user
```

## Quick Start

**1. YAML — simplest form**

Create `gateway.yaml` with a single `schedules:` entry:

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
schedules:
  morning-brief:
    agent: personal
    cron: "0 8 * * *"          # or: every: 24h / at: 2026-01-01T09:00
    deliver:
      channel: telegram
      channel_id: ${OWNER_CHAT_ID}
    prompt: "Summarise my calendar and unread priorities for today."
```

Start the gateway — the schedule loads at boot and fires through the shared tick + delivery loop:

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
praisonai gateway start --config gateway.yaml
```

**2. CLI — manage schedules from the terminal**

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
praisonai gateway schedule add morning-brief \
  --agent personal \
  --cron "0 8 * * *" \
  --channel telegram \
  --channel-id 12345 \
  --prompt "Summarise my calendar and unread priorities for today."

praisonai gateway schedule list
praisonai gateway schedule remove morning-brief
```

**3. Agent-centric — wire the agent in Python**

The schedule references an agent by id; define that agent so the gateway can run it on each fire:

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

agent = Agent(
    name="personal",
    instructions="Summarise the user's calendar and unread priorities each morning.",
)
```

***

## How It Works

The scheduler fires on the trigger, runs the agent, and routes the reply through the same channel-bot send path as inbound hooks.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant TM as Cron/Timer
    participant SC as Scheduler
    participant AG as Agent
    participant CB as Channel Bot
    participant US as User

    TM->>SC: Trigger fires (cron / every / at)
    SC->>SC: pre_run gate (optional go/no-go)
    SC->>AG: Run agent on prompt
    AG-->>SC: Agent reply
    SC->>CB: Deliver reply to channel:channel_id
    CB-->>US: Message delivered
```

| Concept | YAML key | CLI flag |
| - | - | - |
| Schedule name | map key | positional `name` |
| Agent to run | `agent` | `--agent` |
| Prompt per fire | `prompt` | `--prompt` |
| Cron trigger | `cron` | `--cron` |
| Interval trigger | `every` | `--every` |
| One-shot trigger | `at` | `--at` |
| Delivery platform | `deliver.channel` | `--channel` |
| Delivery target id | `deliver.channel_id` | `--channel-id` |
| Go/no-go gate | `pre_run` | `--pre-run` |
| Active? | `enabled` | — |

***

## Choosing a Trigger — `cron` vs `every` vs `at`

Exactly one of `cron` / `every` / `at` must be set on each schedule.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q{When should it fire?} --> R{Recurring?}
    R -->|Yes, wall-clock| CR[cron<br/>0 8 * * *]
    R -->|Yes, fixed interval| EV[every<br/>24h / 30m / 10s]
    R -->|No, once| AT[at<br/>2026-01-01T09:00]

    classDef q fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef cron fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef every fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef at fill:#6366F1,stroke:#7C90A0,color:#fff

    class Q,R q
    class CR cron
    class EV every
    class AT at
```

| Trigger | Use for | Example |
| - | - | - |
| `cron` | Recurring at wall-clock times | `"0 8 * * *"` (daily 8am) |
| `every` | Fixed interval from last fire | `"24h"`, `"30m"`, `"10s"` |
| `at` | One-shot at a specific timestamp | `"2026-01-01T09:00"` |

***

## Schedule Reference

Every field on a `schedules:` entry.

| Option | Type | Default | Description |
| - | - | - | - |
| `agent` | `str` | — (required) | Agent id to run on each fire. |
| `prompt` | `str` | — (required) | Prompt the agent runs on each fire. |
| `cron` | `str \| None` | `None` | Cron expression (`"0 8 * * *"`). Exactly one of `cron` / `every` / `at`. |
| `every` | `str \| None` | `None` | Interval (`"24h"`, `"30m"`, `"10s"`, or raw seconds). Exactly one of `cron` / `every` / `at`. |
| `at` | `str \| None` | `None` | One-shot ISO timestamp (`"2026-01-01T09:00"`). Exactly one of `cron` / `every` / `at`. |
| `deliver` | `DeliveryTarget \| None` | `None` | Delivery target — omit for run-only jobs. |
| `pre_run` | `str \| None` | `None` | Optional cheap go/no-go command run before the model turn. `"nothing to do"` skips with no tokens spent and no delivery. |
| `enabled` | `bool` | `True` | Whether the schedule is active. |

Zero triggers raises `schedule requires exactly one of 'cron', 'every' or 'at'`; more than one raises `schedule accepts only one of 'cron', 'every' or 'at'`.

***

## Delivery

`deliver:` reuses the same channel-bot send path as inbound hooks — omit it for a run-only job with no channel post.

| Option | Type | Default | Description |
| - | - | - | - |
| `channel` | `str` | — (required) | Platform name (`telegram`, `discord`, `slack`, …). |
| `channel_id` | `str` | `""` | Platform chat / channel id. |
| `thread_id` | `str \| None` | `None` | Optional thread id within the channel. |
| `continuable` | `bool` | `True` | One-way notification vs. resumable opener. Default lets a reply in the same chat resume the conversation. |

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
schedules:
  digest:
    agent: personal
    every: 6h
    deliver:
      channel: slack
      channel_id: "#daily"
      continuable: false        # one-way alert, no conversation thread
    prompt: "Post the latest metrics digest."
```

> [!NOTE]
>
> Delivered results are sent as `agent_finished` notifications. When `gateway.notifications` is configured, the recipient's [Notification Policy](https://praison.ai/docs/features/gateway-notification-policy.md) decides whether and how much of the result is posted.

***

## Pre-run Gate

Set `pre_run` to a cheap shell/python command that runs before the model turn — if it outputs `nothing to do`, the run is skipped with no LLM call and no delivery.

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
schedules:
  newsletter:
    agent: writer
    cron: "0 9 * * 1"
    pre_run: "test -f /tmp/newsletter.md"
    deliver:
      channel: telegram
      channel_id: ${OWNER_CHAT_ID}
    prompt: "Draft this week's newsletter from /tmp/newsletter.md."
```

***

## Idempotency & Hot-Reload

Each schedule gets a stable id `cfg-<sha1(gateway-schedule:{name})[:12]>` derived from its YAML key, so re-loading the same config upserts instead of duplicating.

* **Idempotent on stable id** — the SHA1-prefixed id is deterministic from the YAML key; boots and hot-reloads never accumulate duplicates.
* **`last_run_at` is carried forward** on upsert, so an interval or one-shot job is not re-fired after a restart or edit.
* **Removed schedules stop firing** — a schedule deleted from the YAML is pruned on the next reload (only `cfg-` prefixed jobs are ever pruned).
* **Chat-created jobs are untouched** — jobs created in-chat via the agent-callable `schedule` tool carry random ids and are never reconciled.

`GatewayServer._load_declarative_schedules(config)` runs right before the scheduler tick starts and again on every hot-reload, so additions, edits and removals all take effect without a process restart.

***

## Failure Semantics

The gateway loads schedules best-effort so a single bad entry never blocks startup.

* **Malformed entries are skipped, not fatal** — a single bad entry logs `Skipping invalid schedule 'x': ...` and startup continues. A warning also surfaces at load time.
* **Refuses to clobber a broken config** — CLI `add` / `remove` will not overwrite a `gateway.yaml` it could not read as a YAML mapping; the original file is preserved and the command exits `1`.
* **Exit codes** — passing zero or more than one trigger flag, or a missing `--agent` / `--prompt`, exits `1`.

***

## Tick Resilience

The scheduler tick loop recovers from errors on its own, so one bad tick never stops your schedules.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Loop as Tick loop
    participant Policy as RunPolicy refresh
    participant Tick as tick_all()
    participant Log as Logger

    Loop->>Policy: Rebuild policy from config
    Policy-->>Loop: policy
    Loop->>Tick: Run due jobs
    alt tick ok
        Tick-->>Loop: results
        Note over Loop: failures = 0, sleep(interval)
    else tick raises (e.g. EMFILE on store lock)
        Tick-->>Log: Scheduler tick failed (N in a row)
        Note over Loop: failures += 1, sleep with backoff
    end
    Note over Loop: Next tick runs, task stays alive
```

| Scenario | Behaviour |
| - | - |
| Store lock `open()` fails with `EMFILE` | Logged with traceback; next tick retried with backoff |
| `scheduler:` block hot-reloaded as a non-mapping (e.g. a list) | Warning logged; fail-closed defaults used; loop keeps ticking |
| Any other exception in a tick | Logged with traceback; retried with backoff |
| Gateway shutdown (`asyncio.CancelledError`) | Loop exits cleanly |

* **Backoff** — after a failed tick the loop sleeps `min(interval * 2 ** min(failures, 10), 300)` seconds, so retries never wait longer than 5 minutes.
* **Reset on success** — the first successful tick resets `failures` to `0` and the loop goes back to the normal `interval`.
* **Visible, not silent** — each failure logs `Scheduler tick failed (N in a row)` with a full traceback, so a stuck store shows up in the logs even while chat channels and `/health` look fine.

> [!NOTE]
>
> The startup log line reads `Scheduler tick started (interval=Ns)` and shows the real interval. Older releases always printed `15s`, whatever the interval was.

> At 08:00 the `morning-brief` schedule is due, but the store lock file briefly hits `EMFILE`. The gateway logs the error, waits `interval × 2` seconds, retries, and the brief arrives a little late. No restart is needed.

***

## CLI Reference

### `add`

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
praisonai gateway schedule add <name> \
  --agent <agent-id> \
  --prompt "..." \
  (--cron "0 8 * * *" | --every 24h | --at 2026-01-01T09:00) \
  [--channel telegram] [--channel-id 12345] \
  [--pre-run "..."] \
  [--config gateway.yaml]
```

| Flag | Required | Description |
| - | - | - |
| `name` (positional) | yes | Schedule name, e.g. `morning-brief`. |
| `--agent` | yes | Agent id to run. |
| `--prompt` | yes | Prompt the agent runs on each fire. |
| `--cron` / `--every` / `--at` | one exactly | Trigger. |
| `--channel` | no | Delivery platform. |
| `--channel-id` | no | Delivery chat/channel id. |
| `--pre-run` | no | Optional shell go/no-go gate. |
| `--config` | no | Path to `gateway.yaml` (default `gateway.yaml`). |

### `list`

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
praisonai gateway schedule list [--config gateway.yaml]
```

Prints one line per schedule with agent, trigger, and delivery target:

```
Schedules in gateway.yaml:
  morning-brief  agent=personal cron=0 8 * * *  deliver=telegram:12345
```

### `remove`

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
praisonai gateway schedule remove <name> [--config gateway.yaml]
```

***

## Real User-Interaction Flow

> At 8am Telegram chat `${OWNER_CHAT_ID}` gets a summary of the user's calendar and unread priorities. The user can reply in the same chat — because `continuable: true` — and the `personal` agent resumes the conversation.

***

## Common Patterns

### Daily brief to Telegram (cron)

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
schedules:
  morning-brief:
    agent: personal
    cron: "0 8 * * *"
    deliver:
      channel: telegram
      channel_id: ${OWNER_CHAT_ID}
    prompt: "Summarise my calendar and unread priorities for today."
```

### Hourly health-check poll to Slack (every)

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
schedules:
  health-poll:
    agent: ops
    every: 1h
    deliver:
      channel: slack
      channel_id: "#ops"
    prompt: "Check service health and report any anomalies."
```

### One-shot launch reminder (at)

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
schedules:
  launch-reminder:
    agent: personal
    at: "2026-01-01T09:00"
    deliver:
      channel: telegram
      channel_id: ${OWNER_CHAT_ID}
    prompt: "Remind me that the launch checklist is due today."
```

***

## Best Practices

> [accordion] Use a pre\_run gate for expensive automations
>
> A cheap `pre_run` command that prints `nothing to do` skips the model turn entirely — no tokens spent and no delivery when there's nothing new.

> [accordion] Prefer every for jitter-tolerant polling, cron for wall-clock deliveries
>
> Use `every` when the exact minute doesn't matter (health polls); use `cron` when the delivery must land at a specific wall-clock time (an 8am brief).

> [accordion] Keep schedule names stable
>
> The stable id derives from the YAML key — renaming a schedule creates a new job and drops the old one's run-state.

> [accordion] Set continuable: false for one-way notifications
>
> A bare alert that shouldn't open a conversation thread should set `continuable: false` so a reply doesn't resume an agent turn.

***

## Related

> [card] [Gateway Inbound Hooks](https://praison.ai/docs/features/gateway-inbound-hooks.md)
>
> The inbound HTTP trigger counterpart to this outbound scheduled surface.

> [card] [Schedule CLI](https://praison.ai/docs/cli/schedule.md)
>
> The standalone `praisonai schedule` CLI — store poller, tick, and job management.

> [card] [Proactive Delivery](https://praison.ai/docs/features/proactive-delivery.md)
>
> Friendly aliases for scheduled delivery.

> [card] [Gateway Overview](https://praison.ai/docs/features/gateway-overview.md)
>
> Gateway architecture and how channels, agents, and routing connect.

> [card] [Scheduled Run Policy](https://praison.ai/docs/features/scheduled-run-policy.md)
>
> RunPolicy for scheduled jobs.

> [card] [Scheduler Multi-Tenant](https://praison.ai/docs/features/scheduler-multi-tenant.md)
>
> Per-user isolation for scheduled jobs.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.