Scheduled triggers (cron)
A scheduled trigger posts one event to your function every time its cron schedule comes round. The function does the work; the trigger owns the clock. This page covers exactly what arrives at your handler, how retries work, and the three execution-policy behaviors that surprise everyone.
Triggers live on the Serverless API, but a trigger can target a function — the same platformctl serverless triggers create command, with your function's name as the --target.
Exactly what arrives at your handler
Each firing sends one request:
POST / HTTP/1.1
Content-Type: application/json
ce-specversion: 1.0
ce-id: 3f1a9c22b70d48e1
ce-source: //platform.crusoe.ai/projects/ab12cd/triggers/nightly-rollup
ce-type: ai.crusoe.trigger.schedule
ce-time: 2026-08-12T02:00:00Z
ce-deliveryattempt: 1
{"job":"rollup"}
Point by point, for a function target:
- Method is always
POST— even when there is nothing to send. - The body is your configured
payload, character for character. Leave the payload blank and the body is{}— a valid empty JSON object, so a JSON-parsing handler never needs a special case. - The
ce-idheader makes it a CloudEvent under the shim's rules: your handler's return value is discarded and the shim answers204. Write the handler for what it does — and observe it throughplatformctl logs --history. - Whether the
ce-*headers reach your handler depends on the runtime. On Python, the shim strips thece-prefix and hands them over asevent["_cloudevent"], soevent["_cloudevent"]["source"]tells one trigger from another andevent["_cloudevent"]["deliveryattempt"]recognises a redelivery. On Node.js, Go and Ruby the headers really are gone and the handler sees only the JSON body — so on those runtimes, if a function is fired by several triggers and has to tell them apart, put the identity in the payload. Read the key defensively either way:event.get("_cloudevent", {}).get("source"). ce-idis unique per firing and stable across retries of that firing;ce-deliveryattemptcounts up from 1. Together they let an idempotent handler recognize a repeat — on Python by reading_cloudevent, on the other three by whatever you put in the payload. Neither works within one function instance: a scale-to-zero function has no memory between runs unless it stores state somewhere.- An exception raised inside a CloudEvent delivery is answered
400. For a trigger, any4xxis terminal — never retried. A handler that crashes on a temporary problem loses the run forever. Catch it and return normally when you want the retry. The full retry table, backoff modes, and the 30-second-per-attempt limit are on triggers.
A complete worked example
The function — sweep pending rows every 15 minutes:
- Python
- Node.js
- Go
- Ruby
def handle(event: dict) -> dict:
job = event.get("job", "sweep")
print(f"scheduled run: {job}")
# ... do the work; look up rows added since the last run ...
return {"statusCode": 200, "ok": True, "job": job} # discarded on trigger firings
'use strict';
function handle(event) {
const job = (event && event.job) || 'sweep';
console.log(`scheduled run: ${job}`);
// ... do the work ...
return { statusCode: 200, ok: true, job };
}
module.exports = { handle };
package main
func Handle(event map[string]any) (map[string]any, error) {
job, _ := event["job"].(string)
if job == "" {
job = "sweep"
}
// ... do the work ...
return map[string]any{"statusCode": 200, "ok": true, "job": job}, nil
}
def handle(event)
job = (event['job'] if event.is_a?(Hash)) || 'sweep'
# ... do the work ...
{ 'statusCode' => 200, 'ok' => true, 'job' => job }
end
Deploy it, then wire the schedule:
- platformctl
- curl
- Console
platformctl functions deploy ./my-function --name sweeper
platformctl serverless triggers create quarter-hourly \
--target sweeper \
--type schedule \
--cron '*/15 * * * *' \
--payload '{"job":"sweep"}'
You should see:
NAME TYPE SOURCE TARGET STATE LAST RUN
quarter-hourly schedule */15 * * * * sweeper pending -
pending is the normal first answer: the trigger object exists, and its schedule is wired up a moment later.
curl -s -X POST "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "quarter-hourly",
"target": {"service": "sweeper", "path": "/"},
"source": {"type": "schedule",
"schedule": {"cron": "*/15 * * * *", "payload": "{\"job\":\"sweep\"}"}}}'
Note two things: payload is a string holding JSON (which is why the quotes inside it are escaped), and triggers live on the serverless API — $CAI_SERVERLESS_API, not $CAI_API. Both serve the same hostname.
The function's own page has an Add trigger button that opens the same dialog the Serverless page uses, with this function fixed as the target. Set Trigger to Schedule, name it, and give it a Cron and a Payload. The dialog reads your cron expression back in plain English as you type.
Leave target.path at its default of /, which is where the function shim answers. (Point a trigger at an agent instead and you almost certainly want /invoke.)
Writing the cron expression
Five fields — minute, hour, day-of-month, month, day-of-week — or an @ shorthand. The API validates at creation, so a typo is a refusal now, not a silent never-firing trigger:
*/15 * * * * every 15 minutes
0 2 * * * 02:00 every day
0 0 * * MON-FRI midnight on weekdays
@daily once a day
@every 90m every 90 minutes
Accepted shorthands: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly, and @every plus a duration.
Most crontab(5) implementations accept 7 for Sunday. This one does not:
the day of week field has 7, outside the allowed 0-6 - Sunday is 0 here, not 7, unlike crontab(5)
This is the single most common mistake when copying an expression from a server crontab.
Set --time-zone (source.schedule.time_zone) to a region name such as America/Chicago when the wall-clock time matters. Default is UTC. Pick a daylight-saving zone and the schedule shifts twice a year — once skipping an hour, once running it twice. UTC never does either.
Retries, briefly
- Any
2xx: success.429or any5xx, or no response: retried up toretry.max_attempts(default 5, meaning one try plus four). Any other4xx: terminal, never retried. - Exponential backoff by default: 1 s, 2 s, 4 s, 8 s between attempts, each wait capped at 60 s.
- Each attempt gets 30 seconds. A handler still working at 30 seconds counts as a failed attempt.
- A handler exception on a CloudEvent delivery is answered
400by the shim — so a crashing handler is terminal too. Catch it insidehandleand return normally when you want the retry.
Execution policy: three behaviors that surprise people
1. A missed window is skipped, not caught up. If the platform cannot start a firing within 60 seconds of its time, that firing is dropped forever — no backlog replay when things recover. So do not treat one firing as one unit of work. Have the job look up its own work each run (rows added since the last processed one), which also makes the handler naturally idempotent.
2. Firings never overlap. If a delivery is still running when the next window comes round, that window is skipped. An hourly trigger whose work takes 70 minutes fires roughly every two hours, not every hour. If your schedule seems to run at half the rate you set, this is why.
3. retry.max_attempts is the only retry knob. The job underneath never retries itself, so what you configure is exactly what you get.
Observing runs
Trigger firings land in the trigger's run logs; handler output lands in the function's persisted logs:
platformctl logs sweeper --history
A function fired every 15 minutes spends nearly all of its life scaled to zero — live logs will almost always show nothing, which is normal. --history reads what was written before the instance went away.
Next steps
- Triggers — the full retry/backoff tables and the exact terminal-error log lines.
- Language guides — per-runtime handler patterns.
- HTTP and events — the CloudEvent rules the shim applies to every trigger firing.