Scheduled Jobs (Crons)
Run jobs on a recurring schedule. Powered by pg_cron. The Core manages scheduling, overlap detection, and delivery to your worker via the same Job Queue.
How it works
When you create a cron, the Core registers it with pg_cron inside PostgreSQL. On each tick, pg_cron calls a function (rootcx_system.enqueue_cron) that enqueues a job into the same pgmq queue used by one-shot jobs. The scheduler picks it up and dispatches it to your worker's onJob handler.
Your worker does not need to know whether a job came from a cron or a manual enqueue. The payload contains a cron_id field that identifies it as a cron job. Dispatch is otherwise identical.
Schedule syntax
Two formats are supported:
Standard cron (5 fields: minute hour day-of-month month day-of-week):
* * * * * # every minute
*/5 * * * * # every 5 minutes
0 9 * * * # daily at 9 AM
0 0 * * 1-5 # midnight on weekdays
0 0 1,15 * * # 1st and 15th of each month
0 0 $ * * # midnight on the last day of each month
Seconds interval (for sub-minute scheduling, 1-59):
10 seconds # every 10 seconds
30 seconds # every 30 seconds
1 seconds # every second
All times are GMT unless you pass a timezone parameter.
Validation rules
- Standard cron: exactly 5 whitespace-separated fields. Only digits,
*,/,-,,,$allowed. - Seconds interval: a number 1-59 followed by
seconds. - Named shortcuts (
@daily,@hourly) are not supported. - Alphabetic day/month names (
MON,JAN) are not supported.
Overlap policy
Controls what happens when a cron fires but the previous job is still in the queue.
| Policy | Behavior |
|---|---|
skip (default) |
Do not enqueue if a job with the same cron_id is already in the queue. Prevents pile-up. |
queue |
Enqueue regardless. Jobs may stack up. |
Declare crons in the manifest
Crons can be declared directly in manifest.json. They are synced on every deploy (created, updated, or deleted to match the manifest):
{
"crons": [
{
"name": "daily-check",
"schedule": "0 9 * * *",
"timezone": "America/New_York",
"method": "onDailyCheck",
"payload": { "task": "check_replies" },
"overlapPolicy": "skip"
}
]
}
| Field | Required | Default | Description |
|---|---|---|---|
name |
Yes | Unique name within the app. 1-64 chars, alphanumeric/dash/underscore. | |
schedule |
Yes | Cron expression or seconds interval. | |
timezone |
No | GMT | Timezone for the schedule. |
method |
No | If set, added to the payload as payload.method. Useful for routing in onJob. |
|
payload |
No | {} |
JSON object passed to the worker. |
overlapPolicy |
No | "skip" |
"skip" or "queue". |
On deploy, the Core syncs the manifest crons: new entries are created, changed entries are updated, entries removed from the manifest are deleted.
Create crons at runtime
POST /api/v1/apps/{appId}/crons
Requires app:{appId}:cron.write permission.
{
"name": "daily-check",
"schedule": "0 9 * * *",
"timezone": "America/New_York",
"payload": { "task": "check_replies" },
"overlapPolicy": "skip"
}
Response (201):
{
"id": "f47ac10b-...",
"appId": "crm",
"name": "daily-check",
"schedule": "0 9 * * *",
"timezone": "America/New_York",
"payload": { "task": "check_replies" },
"overlapPolicy": "skip",
"enabled": true,
"pgJobId": 7,
"createdBy": "a1b2c3d4-...",
"createdAt": "2024-12-01T10:30:00Z",
"updatedAt": "2024-12-01T10:30:00Z"
}
Update a cron
PATCH /api/v1/apps/{appId}/crons/{id}
Requires app:{appId}:cron.write permission. Only the owner or a user with app:{appId}:cron.manage_others can update another user's cron.
All fields optional:
{ "schedule": "*/5 * * * *", "enabled": false, "overlapPolicy": "queue" }
When enabled is set to false, the pg_cron job is unscheduled. When re-enabled, it is rescheduled.
Delete a cron
DELETE /api/v1/apps/{appId}/crons/{id}
Requires app:{appId}:cron.write permission plus ownership (or manage_others). Unschedules the pg_cron job and removes the record.
List crons
GET /api/v1/apps/{appId}/crons
Requires app:{appId}:cron.read permission. Returns all crons for the app.
GET /api/v1/crons
Lists crons across all apps the user has access to. Users with cron.manage_others see all crons for that app. Others see only their own.
Trigger a cron manually
POST /api/v1/apps/{appId}/crons/{id}/trigger
Requires app:{appId}:cron.trigger permission. Fires the cron once immediately, bypassing the schedule. Enqueues a job with the cron's payload. Returns the enqueued job's message ID:
{ "msgId": 42 }
List cron runs
GET /api/v1/apps/{appId}/crons/{id}/runs?limit=50
Requires app:{appId}:cron.read permission. Returns execution history from pg_cron's cron.job_run_details:
[
{
"runid": 101,
"jobPid": 12345,
"status": "succeeded",
"returnMessage": "1 row",
"startTime": "2024-12-01T09:00:00Z",
"endTime": "2024-12-01T09:00:01Z"
}
]
Default limit: 50, max: 500.
Handle cron jobs in your backend
Cron jobs arrive in the onJob handler, same as regular jobs. The payload contains everything you set at creation time plus a cron_id field added by the Core:
serve({}, {
onJob: async (payload, caller, ctx) => {
if (payload.task === "check_replies") {
// your logic here
return { checked: true };
}
},
});
Return a value to mark the job as completed. Throw to mark it as failed.
Cron-triggered agents
If the app is an AI agent, the scheduler detects this and invokes the agent instead of dispatching to onJob. The agent receives payload.message as the invocation message (or "Scheduled invocation" if no message is set).
See Build an AI Agent for details.
Permissions
| Permission | Grants |
|---|---|
app:{appId}:cron.read |
List crons and view run history |
app:{appId}:cron.write |
Create, update, delete crons |
app:{appId}:cron.trigger |
Fire a cron manually |
app:{appId}:cron.manage_others |
Manage crons created by other users |
cron.read, cron.write, and cron.trigger are auto-generated when an app is installed (if no explicit permissions are declared). cron.manage_others must be declared explicitly or granted via wildcard.
Technical details
| Property | Value |
|---|---|
| Backend | pg_cron extension |
| Requirement | shared_preload_libraries = 'pg_cron' in postgresql.conf |
| Queue | Same pgmq jobs queue as one-shot jobs |
| Overlap detection | SQL check in rootcx_system.enqueue_cron(): queries pgmq.q_jobs for existing cron_id |
| Cron name constraints | 1-64 chars, alphanumeric + dash + underscore |
| Response format | camelCase (serde rename_all) |
API endpoints summary
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /api/v1/crons |
cron.read per app |
List crons across all accessible apps |
| GET | /api/v1/apps/{appId}/crons |
app:{appId}:cron.read |
List crons for an app |
| POST | /api/v1/apps/{appId}/crons |
app:{appId}:cron.write |
Create a cron |
| PATCH | /api/v1/apps/{appId}/crons/{id} |
app:{appId}:cron.write |
Update a cron |
| DELETE | /api/v1/apps/{appId}/crons/{id} |
app:{appId}:cron.write |
Delete a cron |
| GET | /api/v1/apps/{appId}/crons/{id}/runs |
app:{appId}:cron.read |
List execution history |
| POST | /api/v1/apps/{appId}/crons/{id}/trigger |
app:{appId}:cron.trigger |
Fire immediately |