Scheduled tasks
Declare cron-triggered jobs the platform runner fires against your app — targets, retries, timezone and scope — synced into the Control Plane scheduler on publish.
The platform runs your cron jobs for you
Some work is periodic, not request-driven — sweep timed-out records, roll up analytics, re-drive a stuck queue. You do not stand up your own cron container for that. You declare the jobs as scheduled_tasks in app.manifest.yaml, and the platform's scheduler owns the timing: at manifest publish the Control Plane syncs each task into its scheduled_jobs table, and a single platform runner fires each one on its cron, calling an internal endpoint on your app. Your app just exposes the endpoint and does the work when called.
The runner resolves your service's base_url and inbound x-api-key centrally at dispatch time — you never hardcode either. Because dispatch lands on the internal auth tier, every task target is an x-api-key-gated route under /v1/<app_code>/internal/... (see Auth & the five gates).
A task is one entry in the array. code, name, cron, and target are the only required keys; everything else has a default.
scheduled_tasks:
- code: example_cleanup
name: Example Cleanup
description: Example periodic job. Replace with a real maintenance task or remove.
cron: "0 * * * *"
timezone: UTC
target:
kind: http_sync
method: POST
path: /v1/myservice/internal/jobs/example-cleanup
scope: per_app
enabled_by_default: false
timeout_seconds: 60Task field reference
| Field | Required | Purpose |
|---|---|---|
code | yes | Unique within the app (^[a-z][a-z0-9_]*$, ≤100 chars). Becomes part of the CP row identity. |
name | yes | Human-readable label (1–200 chars) shown in the CP scheduled-jobs UI. |
description | no | What the job does and why it matters — the operator reads this. |
cron | yes | Standard 5-field expression (min hour dom month dow). Validated by croniter at publish; an invalid expression fails the publish. |
timezone | no | IANA/UTC zone the cron is evaluated in (≤64 chars). Default UTC. |
target | yes | How the runner reaches you — see below. |
scope | no | per_instance | per_app | platform. Default per_instance. |
instances_of | no | per_instance only. Seed one row per instance of another app instead of your own — for apps whose tenant data lives in another app's per-instance database. See Scope. |
enabled_by_default | no | Whether the synced job starts enabled. Default true. |
requires_feature | no | A plan feature code that must be enabled for the org to fire. When off, the run is skipped (not failed). |
timeout_seconds | no | Per-run wall-clock cap, 1–86400. Default 600. |
poll_interval_seconds | no | For http_async: seconds between status polls, 5–3600. |
poll_timeout_seconds | no | For http_async: how long to keep polling before giving up, 60–86400. |
retries | no | Retry policy on failure — see below. |
disable_after_consecutive_failures | no | Per-job override (1–1000) of the scheduler's auto-disable threshold. Leave unset to use the platform default (SCHEDULER_DISABLE_AFTER_CONSECUTIVE_FAILURES). Critical jobs should declare a higher floor. |
input_params | no | A free-form object handed to the job on each run (e.g. a limit or days window). |
requires_feature gates a run on a plan feature (see Plans & features); the job stays scheduled but no-ops for orgs that don't have the feature. Prefer it over checking the entitlement inside your handler.The target — how the runner reaches you
target.kind selects the dispatch strategy. Only http_sync and http_async are available to a normal app; fargate_task is reserved for a future release, and internal is reserved for the Control Plane's own manifest.
kind | Behaviour |
|---|---|
http_sync | The runner POSTs (or PUTs) to path and waits for a 2xx. A non-2xx or a timeout is a failure. |
http_async | The runner calls path, expects 202 plus a body carrying {task_id}, then polls poll_path (with {task_id} substituted) on poll_interval_seconds until it reports done or poll_timeout_seconds elapses. Use this for long jobs that outlive a single request. |
fargate_task | Reserved for v2 — runs an ECS task from task_definition / cluster instead of hitting an endpoint. Not yet dispatched. |
internal | In-process handler inside the Control Plane. path is the registered handler key, not a URL. Reserved for the platform: CP rejects an internal-kind target from any app_code other than control-plane. |
| Target field | Purpose |
|---|---|
kind | One of the four strategies above. Required. |
method | POST or PUT. Default POST. |
path | Relative path on your app for http_sync / http_async. Must start with /v1/{app_code}/internal/. |
poll_path | Status-poll path for http_async. Must contain the {task_id} placeholder. |
task_definition | ECS task definition family:revision — required when kind: fargate_task. |
cluster | ECS cluster for kind: fargate_task. |
# Long-running job: fire, get a task id, then poll for completion.
scheduled_tasks:
- code: nightly_reindex
name: Nightly search reindex
description: Rebuilds the tenant search index; runs longer than one request.
cron: "0 3 * * *"
timezone: UTC
target:
kind: http_async
method: POST
path: /v1/myservice/internal/jobs/reindex
poll_path: /v1/myservice/internal/jobs/reindex/{task_id}
scope: per_instance
poll_interval_seconds: 30
poll_timeout_seconds: 3600
timeout_seconds: 60Scope — how many rows one task becomes
scope decides how many scheduled_jobs rows the single manifest entry expands into.
scope | Expands to | Use for |
|---|---|---|
per_instance | One job row per active tenant instance, with start times staggered so they don't all fire at once. | Per-tenant maintenance — sweepers, timeout scans, per-instance rollups. |
per_app | One job row platform-wide. | Work that spans all tenants at once, or an app that isn't per-instance scoped. |
platform | One platform-wide row, reserved for the Control Plane. CP rejects platform scope from any app_code other than control-plane. | Platform ticks (provisioning drain, pool reconcile). |
When your tenant data lives in another app's database
A per_instance dispatch carries the instance's identifier in the X-App-Instance-Guid header, and your handler normally uses it to open that tenant's database. That works because your app owns a database per instance.
Some apps instead share another app's per-instance database — they read and write the same tenant data, filtered by instance. Those apps have no database registered against their own instances, so a job seeded that way sends an identifier nothing can resolve: the handler answers 503 and the platform records the run as skipped. Skipped is a deliberate state — one unprovisioned tenant must never switch off a job that serves everyone else — so nothing fails, nothing is disabled, and the task can sit dormant for a long time before anyone notices.
instances_of names the app that owns the database, and the seeding follows it:
scheduled_tasks:
- code: workflow_orphan_run_sweeper
name: Re-arm orphaned workflow runs
cron: "*/5 * * * *"
target:
kind: http_sync
method: POST
path: /v1/automation/internal/notifications/sweep-orphaned-runs
scope: per_instance
instances_of: automationOne row per active automation instance. Your own service still receives the request — the platform resolves your base_url and x-api-key as always — and only the instance identifier in the header comes from the named app, which is exactly the tenant whose data you are there to work on.
Publishing a per_instance task without instances_of from an app that does not own per-instance databases returns a warning on the import response. It is a warning rather than a rejection: a per-instance task that never opens a tenant database is perfectly valid.
Retries and auto-disable
retries controls what happens when a single run fails; disable_after_consecutive_failures controls when a persistently failing job is switched off entirely.
| Retry field | Purpose |
|---|---|
max_attempts | Total attempts per scheduled run, 1–10. Default 3. |
backoff | exponential or fixed. Default exponential. |
initial_delay_seconds | Delay before the first retry, 1–600. Default 30. |
If a job fails on every run, the scheduler eventually disables it so it stops paging. disable_after_consecutive_failures raises that floor per-job — a critical job (a provisioning drain, a migration runner) should set it well above the platform default so a run of transient failures can't silently kill the mechanism.
Examples
A per-instance sweeper — one job per active tenant instance, synchronous, short timeout:
scheduled_tasks:
- code: workflow_timeout_sweeper
name: Sweep timed-out workflow executions
description: Marks workflow executions as TIMED_OUT when their wall-clock cap is exceeded and dispatches timeout notifications. Excludes runs parked on an open human (HITL) decision.
cron: "*/2 * * * *"
timezone: UTC
target:
kind: http_sync
method: POST
path: /v1/automation/internal/notifications/sweep-timeouts
scope: per_instance
timeout_seconds: 60A platform tick from the Control Plane's own manifest — internal kind and platform scope, both reserved for CP, with an elevated auto-disable floor:
scheduled_tasks:
- code: provisioning_tick
name: Provisioning pipeline tick
description: Drain loop for the tenant provisioning state machine. Each tick advances pending steps through the pipeline.
cron: "* * * * *"
timezone: UTC
target:
kind: internal
path: provisioning.tick
scope: platform
enabled_by_default: true
timeout_seconds: 300
retries:
max_attempts: 3
backoff: exponential
initial_delay_seconds: 30
disable_after_consecutive_failures: 30A platform job that passes a tunable window through input_params:
scheduled_tasks:
- code: analytics_rollup_tick
name: Analytics daily rollup
description: Recomputes the analytics rollup the operator dashboard reads. Counts are absolute, so a re-run is idempotent.
cron: "*/20 * * * *"
timezone: UTC
target:
kind: internal
path: analytics.rollup_tick
scope: platform
enabled_by_default: true
timeout_seconds: 180
input_params:
days: 2How it reaches the running service
At manifest publish, CP validates every task — the cron parses under croniter, path starts with /v1/<app_code>/internal/, an http_async target carries a poll_path with {task_id}, and neither internal kind nor platform scope is claimed by a non-platform app — then syncs the tasks into scheduled_jobs (one row for per_app/platform, one per active instance for per_instance — instances of the app named by instances_of when it is set, otherwise your own). From then on the platform runner dispatches on schedule, resolving your base_url and x-api-key centrally rather than from any value baked into the manifest. Make each handler idempotent: a run can be retried, and at-least-once dispatch means the same tick may reach you more than once.
Timing, feature gates, and enablement live in the manifest and CP — not in your process. Your endpoint should do the work and return; whether it runs, how often, and for which tenants is the platform's job. Operator-tunable knobs for a job belong in your config, not in raw environment variables.