Scheduled Crons
Run a SaQL query on a recurring schedule and write the results to an output agent automatically. Crons support both a simple interval mode and a full 5-field crontab expression (UTC). The engine persists last-run state across restarts, so missed ticks are never fired spuriously.
How it works
At startup, the cron engine reads all cron definitions from the internal _crons system agent. It then polls once per minute (for schedule-based crons) or on the configured interval. When a cron fires, the engine:
- Executes the stored SaQL
query_configagainst the source agent. - Writes each result record to the configured
output_agent_id. - Appends a run record to the
_cron_runssystem agent with the status and record count. - Persists the
last_run_mstimestamp to durable storage so a restart does not re-fire the same minute.
Schedule modes
Interval mode
Set interval_secs to a positive integer. The cron fires every N seconds starting from the moment it was last run. Simple and predictable for polling workloads.
POST /v1/crons
Authorization: Bearer spx_root_YOUR_ROOT_KEY
Content-Type: application/json
{
"name": "refresh_leaderboard",
"interval_secs": 300,
"query_config": { "type": "current", "key": "player_id", "limit": 100 },
"output_agent_id": "leaderboard_snapshot",
"enabled": true
}Crontab schedule mode
Set schedule to a standard 5-field cron expression (UTC). The engine evaluates the schedule once per minute against the current UTC time. All fields use UTC — there is no timezone offset support.
┌─────── minute (0–59) │ ┌───── hour (0–23) │ │ ┌─── day of month (1–31) │ │ │ ┌─ month (1–12) │ │ │ │ ┌ day of week (0–6, 0 = Sunday) │ │ │ │ │ * * * * *
| Syntax | Meaning | Example |
|---|---|---|
| * | every unit | * in minute = every minute |
| N | exact value | 15 in minute = :15 past each hour |
| N-M | inclusive range | 9-17 in hour = 9am–5pm |
| */step | every step units | */15 in minute = :00 :15 :30 :45 |
| N-M/step | range with step | 0-30/10 in minute = :00 :10 :20 :30 |
| N,M,... | comma list | 1,15 in day = 1st and 15th of month |
POST /v1/crons
Authorization: Bearer spx_root_YOUR_ROOT_KEY
Content-Type: application/json
{
"name": "nightly_rollup",
"schedule": "0 3 * * *",
"query_config": { "type": "time_range", "start_ms": 0, "limit": 5000 },
"output_agent_id": "daily_rollup",
"enabled": true
}{
"name": "morning_digest",
"schedule": "0 9 * * 1-5",
...
}schedule takes precedence if set. Providing neither returns 400 Bad Request.Persistent last-run tracking
The engine persists each cron's last-run timestamp to durable storage after every successful execution. On restart, the engine reads this value and uses it as the baseline for schedule evaluation. A cron whose scheduled minute already passed before restart will not fire again for that minute — it was already counted.
For interval-based crons the last-run timestamp determines the next fire time. If the server was down for multiple intervals, the cron fires once on resume — not once per missed interval.
HTTP API Reference
| Method | Path | Description |
|---|---|---|
| GET | /v1/crons | List all crons with last-run info. |
| POST | /v1/crons | Create a cron. Returns 201. |
| GET | /v1/crons/:name | Get one cron by name. |
| DELETE | /v1/crons/:name | Tombstone a cron. Returns 204. |
| POST | /v1/crons/:name/enable | Set enabled=true, return updated CronView. |
| POST | /v1/crons/:name/disable | Set enabled=false, return updated CronView. |
| GET | /v1/crons/:name/runs | List up to 50 run records, newest first. |
| POST | /v1/crons/:name/trigger | Fire the cron immediately regardless of schedule. |
Create request body
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | yes | Unique cron name. Must not be empty. |
| interval_secs | number | one of these | Fire every N seconds. Omit if using schedule. |
| schedule | string | one of these | 5-field cron expression (UTC). Omit if using interval_secs. |
| query_config | object | yes | SaQL query to execute. Same shape as a SaQL request body. |
| output_agent_id | string | yes | Agent that receives the query results. |
| description | string | no | Human-readable description. Defaults to empty string. |
| use_interval_window | boolean | no | If true, the query window is pinned to the last interval. Defaults to false. |
| enabled | boolean | no | Whether the cron runs automatically. Defaults to false. |
| created_by | string | no | Attribution label. Defaults to "api". |
CronView response shape
{
"name": "nightly_rollup",
"description": "",
"interval_secs": 0,
"schedule": "0 3 * * *",
"query_config": { ... },
"output_agent_id": "daily_rollup",
"use_interval_window": false,
"enabled": true,
"created_by": "api",
"created_at_ms": 1756224000000,
"last_run_ms": 1756310400000,
"last_run_status": "ok"
}Run record shape
{
"name": "nightly_rollup",
"started_at_ms": 1756310400000,
"duration_ms": 142,
"records_out": 83,
"status": "ok",
"error": null
}Manual trigger
POST /v1/crons/:name/trigger fires the cron immediately and returns the run record. Useful for testing or for backfilling output agents on demand. The trigger does not update last_run_ms used by the scheduler — the next scheduled fire happens at its normal time.
POST /v1/crons/nightly_rollup/trigger Authorization: Bearer spx_root_YOUR_ROOT_KEY
Authentication and Scopes
Creating, deleting, enabling, and disabling crons requires write:crons. Listing and reading crons requires read:crons. A root key always has access.
Holding write:crons alone is not enough to create a cron: SapixDB also checks that the caller holds read:agents/<source>for the query's source agent and write:agents/<output> for output_agent_id — the same way an ordinary per-tenant request would be checked. This closes a gap where a key scoped only to crons could otherwise create a cron reading a source agent it had no relationship to. The same check runs again on POST /v1/crons/:name/trigger, since the caller triggering a cron may not be the one who created it.
A scheduled or triggered run stays bound to its creator's row-policy visibility on the source agent — the JWT claims presented when the cron was created are captured and re-applied on every execution, since the background scheduler has no live caller of its own to check against. See Row-Level Policies.