Skip to main content

Documentation

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.

app.manifest.yamlyaml
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: 60

Task field reference

FieldRequiredPurpose
codeyesUnique within the app (^[a-z][a-z0-9_]*$, ≤100 chars). Becomes part of the CP row identity.
nameyesHuman-readable label (1–200 chars) shown in the CP scheduled-jobs UI.
descriptionnoWhat the job does and why it matters — the operator reads this.
cronyesStandard 5-field expression (min hour dom month dow). Validated by croniter at publish; an invalid expression fails the publish.
timezonenoIANA/UTC zone the cron is evaluated in (≤64 chars). Default UTC.
targetyesHow the runner reaches you — see below.
scopenoper_instance | per_app | platform. Default per_instance.
instances_ofnoper_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_defaultnoWhether the synced job starts enabled. Default true.
requires_featurenoA plan feature code that must be enabled for the org to fire. When off, the run is skipped (not failed).
timeout_secondsnoPer-run wall-clock cap, 1–86400. Default 600.
poll_interval_secondsnoFor http_async: seconds between status polls, 5–3600.
poll_timeout_secondsnoFor http_async: how long to keep polling before giving up, 60–86400.
retriesnoRetry policy on failure — see below.
disable_after_consecutive_failuresnoPer-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_paramsnoA 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.

kindBehaviour
http_syncThe runner POSTs (or PUTs) to path and waits for a 2xx. A non-2xx or a timeout is a failure.
http_asyncThe 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_taskReserved for v2 — runs an ECS task from task_definition / cluster instead of hitting an endpoint. Not yet dispatched.
internalIn-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 fieldPurpose
kindOne of the four strategies above. Required.
methodPOST or PUT. Default POST.
pathRelative path on your app for http_sync / http_async. Must start with /v1/{app_code}/internal/.
poll_pathStatus-poll path for http_async. Must contain the {task_id} placeholder.
task_definitionECS task definition family:revision — required when kind: fargate_task.
clusterECS cluster for kind: fargate_task.
app.manifest.yamlyaml
# 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: 60

Scope — how many rows one task becomes

scope decides how many scheduled_jobs rows the single manifest entry expands into.

scopeExpands toUse for
per_instanceOne 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_appOne job row platform-wide.Work that spans all tenants at once, or an app that isn't per-instance scoped.
platformOne 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:

yaml
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: automation

One 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 fieldPurpose
max_attemptsTotal attempts per scheduled run, 1–10. Default 3.
backoffexponential or fixed. Default exponential.
initial_delay_secondsDelay 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:

app.manifest.yamlyaml
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: 60

A platform tick from the Control Plane's own manifest — internal kind and platform scope, both reserved for CP, with an elevated auto-disable floor:

app.manifest.yamlyaml
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: 30

A platform job that passes a tunable window through input_params:

app.manifest.yamlyaml
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: 2

How 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.