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.

Diagram

Gateway

⏰ Trigger
cron / every / at

📅 Scheduler

🤖 Agent Run

📱 Channel

👤 User

Quick Start

  1. YAML — simplest form

    Create gateway.yaml with a single schedules: entry:

    yaml
    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
    praisonai gateway start --config gateway.yaml
  2. CLI — manage schedules from the terminal
    bash
    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
    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.

Diagram
UserChannel BotAgentSchedulerCron/TimerUserChannel BotAgentSchedulerCron/TimerTrigger fires (cron / every / at)pre_run gate (optional go/no-go)Run agent on promptAgent replyDeliver reply to channel:channel_idMessage delivered
ConceptYAML keyCLI flag
Schedule namemap keypositional name
Agent to runagent--agent
Prompt per fireprompt--prompt
Cron triggercron--cron
Interval triggerevery--every
One-shot triggerat--at
Delivery platformdeliver.channel--channel
Delivery target iddeliver.channel_id--channel-id
Go/no-go gatepre_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.

Diagram

Yes, wall-clock

Yes, fixed interval

No, once

When should it fire?

Recurring?

cron
0 8 * * *

every
24h / 30m / 10s

at
2026-01-01T09:00

TriggerUse forExample
cronRecurring at wall-clock times"0 8 *" (daily 8am)
everyFixed interval from last fire"24h", "30m", "10s"
atOne-shot at a specific timestamp"2026-01-01T09:00"

Schedule Reference

Every field on a schedules: entry.

OptionTypeDefaultDescription
agentstr— (required)Agent id to run on each fire.
promptstr— (required)Prompt the agent runs on each fire.
cron`str \None`NoneCron expression ("0 8 *"). Exactly one of cron / every / at.
every`str \None`NoneInterval ("24h", "30m", "10s", or raw seconds). Exactly one of cron / every / at.
at`str \None`NoneOne-shot ISO timestamp ("2026-01-01T09:00"). Exactly one of cron / every / at.
deliver`DeliveryTarget \None`NoneDelivery target — omit for run-only jobs.
pre_run`str \None`NoneOptional cheap go/no-go command run before the model turn. "nothing to do" skips with no tokens spent and no delivery.
enabledboolTrueWhether 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.

OptionTypeDefaultDescription
channelstr— (required)Platform name (telegram, discord, slack, …).
channel_idstr""Platform chat / channel id.
thread_id`str \None`NoneOptional thread id within the channel.
continuableboolTrueOne-way notification vs. resumable opener. Default lets a reply in the same chat resume the conversation.
yaml
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."

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

Diagram
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
ScenarioBehaviour
Store lock open() fails with EMFILELogged 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 tickLogged 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.

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
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]
FlagRequiredDescription
name (positional)yesSchedule name, e.g. morning-brief.
--agentyesAgent id to run.
--promptyesPrompt the agent runs on each fire.
--cron / --every / --atone exactlyTrigger.
--channelnoDelivery platform.
--channel-idnoDelivery chat/channel id.
--pre-runnoOptional shell go/no-go gate.
--confignoPath to gateway.yaml (default gateway.yaml).

list

bash
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
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
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
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
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

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.

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

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.

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