Scheduled

The Scheduled capability is how a plugin says "run this thing on a schedule." A Scheduled plugin declares a list of schedules at startup — each one has an id, a cron expression, and an optional config string — and bext's runtime calls run(ctx) on the plugin at the right time, passing a ScheduledRunCtx that tells the plugin which schedule just fired.

It is deliberately a very small shape. The trait surface is four methods: name(), schedules(), run(ctx), and cleanup(). Everything else — cron parsing, missed-run handling, multi-instance locking — is the runtime's job, not the plugin's. That keeps reference implementations like @bext/cron small enough to fit on a page while giving operators a single knob to turn when they need to swap backends.

Declarative, not dynamic

The most important design decision in Scheduled is that schedules are declared, not registered. A plugin does not call a scheduler.register(...) host function at runtime. Instead, when the host asks the plugin what schedules do you have?, the plugin returns the full list in one shot, and the host owns the cron machinery from then on.

The reason this matters: a runtime that owns the schedule list can inspect it, catch overlaps, validate cron syntax before anything fires, persist run history to a store, coordinate multi-instance locking, and display the whole picture in the TUI without calling back into the plugin. A runtime that is handed schedules one-at-a-time through a host function has none of those properties, because it never sees the full shape.

This is also why schedules() returns a Result<Vec, String> and not a stream — a schedule list that cannot be enumerated at startup is, by definition, dynamic state, and dynamic state belongs to the application, not the scheduler.

The trait

pub trait ScheduledPlugin: Send + Sync {
    fn name(&self) -> &str;
    fn priority(&self) -> u32 { 1000 }
    fn schedules(&self) -> Result<Vec, String>;
    fn run(&self, ctx: &ScheduledRunCtx) -> Result<(), String>;
    fn cleanup(&self) -> Result<(), String> { Ok(()) }
}

- name() — a short identifier the runtime uses in logs, metrics, and the TUI. Keep it stable; operators grep for it.

- priority() — when two plugins provide the same schedule id (rare, but possible during migrations) the higher-priority one wins. Default is 1000.

- schedules() — returns the full list of schedules at startup. Called once per plugin load. A Err(...) here aborts plugin load; a mis-configured plugin should fail loud rather than silently run nothing.

- run(ctx) — called each time a schedule fires. ctx.schedule_id tells the plugin which one; if the plugin owns multiple schedules, it dispatches internally on that field.

- cleanup() — called at plugin unload. Default is a no-op, which is the right answer for the vast majority of plugins.

The run() dispatch pattern — one method, keyed on schedule_id — is intentional. An earlier draft had run_<schedule_id> methods, which blew up the trait surface and made the WASM ABI a nightmare. A single run(ctx) keeps the FFI side clean and makes dispatch an if/match inside the plugin, which is cheap.

Schedule

Each schedule is a plain-old-data struct the plugin fills in:

pub struct Schedule {
    pub id: String,
    pub description: String,
    pub cron: String,
    pub jitter_ms: u64,
    pub missed_run_policy: MissedRunPolicy,
    pub locking: LockingHint,
    pub config_json: String,
}

- id is the plugin-local identifier. Must be unique within a plugin.

- description is a human-readable label that shows up in the TUI and logs.

- cron is the cron expression. See the accepted syntax below.

- jitter_ms spreads runs across a random window to avoid thundering-herd thundering — useful when many instances share a clock.

- missed_run_policy tells the runtime what to do when the scheduler missed a fire window (sleep, restart, deploy).

- locking is a hint about whether this schedule must run once per fleet or once per instance.

- config_json is an opaque string the plugin hands to itself — the runtime does not interpret it, but it is part of the schedule shape so operators can see exactly what a schedule is configured to do.

Everything on Schedule is a POD type (no borrowed slices, no generic lifetimes) because schedules cross the WASM ABI and the JSON-string convention keeps the layout flat.

Cron syntax

The reference implementation — @bext/cron — accepts the same subset as bext's built-in scheduler, so the two cannot disagree:

Form Example Meaning
Seconds interval 30s every 30 seconds
Minutes interval 5m every 5 minutes
Hours interval 2h every 2 hours
Shortcut @hourly top of every hour
Shortcut @daily midnight UTC
Cron */N on minutes */5 * * * * every 5 minutes (calendar-aligned)
Cron */N on hours 0 */3 * * * every 3 hours at :00 (calendar-aligned)

This is deliberately a small subset. A full cron grammar is an ocean of edge cases (L, W, nthday-of-month, day-of-week vs. day-of-month, DST) and a runtime that tries to support all of it will ship one scheduler bug per quarter forever. If you need a richer syntax, that is a job for a third-party plugin built on the same trait — and because the trait is capability-shaped, swapping @bext/cron for a richer plugin is a config change, not a rewrite.

MissedRunPolicy

What to do when the scheduler wakes up and discovers it slept through a fire window:

Variant Meaning
RunOnce Fire the schedule exactly once now, even if several windows were missed. Default.
Skip Do nothing; wait for the next regular window.
RunAll Fire the schedule once for every missed window, in order. Use with care.

The default is RunOnce, because it is almost always what you want: an idempotent task (garbage collection, cache warming, metric rollup) should catch up, but not ten times in a burst. RunAll is only a good fit for strictly-ordered workloads where the plugin itself is responsible for the queueing semantics.

When a missed run fires, ctx.is_catchup is set to true on the ScheduledRunCtx. A plugin that wants to behave differently in catchup mode (skip expensive rollups, for example) can check that flag.

LockingHint

Multi-instance deployments need to make sure a given schedule fires once across the fleet, not once per instance. The LockingHint enum tells the runtime how much the plugin cares:

Variant Meaning
NodeLocal Fire per node. Use for per-instance tasks (cache warm, local GC).
RequireGlobal Must fire exactly once across the fleet. Abort the run if no global lock.
PreferGlobal Prefer global, but fall back to per-node if no lock is available. Default.

Multi-instance correctness is a real distributed-systems problem and the final implementation depends on the Locking capability — which is on the roadmap but not yet stable. Today's runtime honors NodeLocal (it is the trivial case), and PreferGlobal degrades to NodeLocal on a single-instance deployment. When the Locking capability lands, RequireGlobal and the global-lock branch of PreferGlobal will start taking a real distributed lease — and no plugin code needs to change for that to happen.

Example: bext.config.toml

The built-in scheduler (which delegates to ScheduledPlugin implementations in a later release) reads from a top-level [[tasks]] array, and @bext/cron accepts the same shape as its own JSON config:

[[tasks]]
id          = "gc"
cron        = "5m"
description = "garbage collect expired tokens"

[[tasks]]
id          = "daily-report"
cron        = "@daily"
description = "email yesterday's traffic report"
config_json = '{"recipients":["ops@example.com"]}'

[[tasks]]
id          = "cache-warm"
cron        = "*/10 * * * *"
description = "warm edge cache for top 100 pages"

Each [[tasks]] entry becomes one Schedule on the active ScheduledPlugin. Switching backends is a one-line config change:

# dev: use the reference plugin, dispatch inside the worker
[scheduled]
provider = "cron"

# prod: use a registry-backed scheduler plugin with a UI,
# same [[tasks]] entries, no code changes
[scheduled]
provider = "my-org-scheduler"

Writing a Scheduled plugin

The shortest possible implementation, which is also roughly what @bext/cron does:

use bext_plugin_api::scheduled::{
    LockingHint, MissedRunPolicy, Schedule, ScheduledPlugin, ScheduledRunCtx,
};
use std::sync::Arc;

type RunCallback = Arc<dyn Fn(&ScheduledRunCtx) -> Result<(), String> + Send + Sync>;

struct MyScheduler {
    schedules: Vec,
    callback: RunCallback,
}

impl ScheduledPlugin for MyScheduler {
    fn name(&self) -> &str {
        "my-scheduler"
    }

    fn schedules(&self) -> Result<Vec, String> {
        Ok(self.schedules.clone())
    }

    fn run(&self, ctx: &ScheduledRunCtx) -> Result<(), String> {
        (self.callback)(ctx)
    }
}

That is the whole plugin. Everything cron-shaped — parsing, firing, catchup, jitter, locking — is the runtime's problem, not yours.

When Scheduled is the wrong shape

Scheduled is for short, repeating, independent work: garbage collection, report emails, cache warmup, health probes. It is deliberately not for:

- Long-running jobs that take hours. A Scheduled run is expected to finish in seconds-to-minutes; anything longer should enqueue work and let a separate worker process it.

- Multi-step flows that need durable state between steps. That is the Workflow capability's job — a future shape that tracks checkpointed progress and can survive restarts mid-flow. When Workflow lands, expect to see it documented next to this page.

- Event-driven work triggered by webhooks, queue arrivals, or file uploads. Those belong to the Queue and Hook capabilities.

If your task is "run this every 10 minutes and be done in under a minute," Scheduled is the right shape. If it is "kick off a 6-hour pipeline every Sunday at 3 a.m.," reach for Scheduled to fire the kickoff and hand the actual work to something durable.

Where to go next

- Capabilities overview covers the promotion ladder and the other four foundational capabilities.

- Task Scheduler guide walks through the built-in [[tasks]] config from an operator's perspective — same shape, different entry point.

- Durable Flows guide is the operator-side pointer to the future Workflow capability.