Scheduled jobs

Run a published handler on a schedule, as its own principal, with the roles and org reach you give it.

A scheduled job runs one of your app's published handlers on an interval or a cron expression. It is the background half of your app: the nightly dunning email, the hourly sync, the weekly clean-up. Create jobs in the console under Code › Scheduled jobs, from a handler with sdk.crons, or over the API.

A job is three things:

  • a schedule: when it fires (Schedules);
  • a principal: who it runs as, and so what it may do (this page);
  • a firing that is carried to completion, however long it takes and whatever fails on the way (Jobs that loop, Runs, failures and Resume).

A job runs as itself

Every job gets its own identity, a principal of the app, and every run is that principal. Never the person who created the job, and never the person who presses Run now.

The obvious alternative, running a job as whoever set it up, is wrong for this shape of thing. A scheduled job is infrastructure with an indefinite lifetime; a person's access is a fluid claim about their current role. Tie the first to the second and someone changing teams silently disarms your nightly invoice sweep, and it fails as a stream of denials nobody is watching. So the two questions are kept apart:

QuestionAnswered byChanges when
What may this job do?The roles bound to its principalYou deliberately bind or remove one
Who set this up?The job's creator, kept for auditNever: it is a record, not a permission

In the handler, ctx.user is the job's principal and the organization it runs as.

Born paused, holding nothing

A new job is created paused, and its principal holds no roles, so it is born unable to do anything. Giving it power is a separate act:

Create it

Name it, choose the published handler it runs, its payload (the data the handler receives on every run), its schedule, and its timeout (per turn, 1 to 3,600 seconds, 300 by default).

Give it roles

On the job's page, under Roles, bind the roles it needs. Binding a role to a job is its own permission (Arm scheduled jobs, crons:assign_cron_role), declared high risk, so an agent asked to do it halts for a person. Effective permissions on the same page shows exactly what the roles add up to.

Choose where it can act

For one of your own jobs, set its org reach. It starts at None.

Activate it

Activate starts the schedule. Activating a job with no roles asks you to confirm, because every governed thing it tries will be refused.

Creating a schedule and giving it power are two decisions, and nothing collapses them into one. That is also why sdk.crons can create a job but can never bind it a role.

How each call is authorized

A job's run is an ordinary run of the handler, with one difference: there is no endpoint in front of it, so there is no endpoint allow-list either. Each SDK call the handler makes is decided by the job principal's own roles. On top of that, the published version's frozen capability manifest still gates the elevated powers: raw SQL, email, storage, cross-org reads.

So a job with no roles still runs: a handler that touches nothing governed is legitimate. It is refused the moment it reaches for something its roles do not allow, and the run history shows that refusal next to the empty role list.

Reviewing a job's roles

A job keeps its roles until you change them. The job list shows each job's bound-role count, and every run records the roles the job actually held at the moment it ran, so you can compare what a job used against what it may do.

Where a job can act

An app's data belongs to the app's organizations. Which of them a job may touch depends on whose job it is.

A job created inside the app (by one of the app's own users, through a handler) belongs to that user's organization. It acts in that organization only, like everything else inside the app.

One of your own jobs, created by you as the app's owner, has an org reach, set on its page under Where it can act:

Org reachThe job reaches
None (the default)No organization's data: only your own records, and anything outside the app (email, outside APIs).
All organizationsEvery organization in the app, including ones that sign up later.
Only theseThe organizations you tick. A new organization is never added on its own.

Org reach decides which organizations; roles decide what the job may do there. A job needs both. Two consequences in the handler:

  • A job whose reach is exactly one organization runs as that organization. Its rows carry no org column and its new rows are that organization's, exactly as if a member of it were running the handler. ctx.audience.mode is org.
  • Any other reach acts for you, the owner. Rows say which org they belong to (org_uid), and a new row in an organization's table must name one. ctx.audience tells the handler which mode it is in; see The handler context.

Only the app's owner, in the console, can change a job's org reach (ORG_REACH_OWNER_ONLY). A job that belongs to one of the app's organizations has no reach to set (ORG_REACH_NOT_APPLICABLE).

What a job can run

A job runs a published handler: the version that is live when it fires, with that version's frozen manifest. If the handler has no published version, the run is recorded as failed and says so. Publish first, then activate.

A handler written only to be run by its jobs (a bulk mailing, a sweep over every organization) should be job-only: then no endpoint, agent, MCP tool or key can run it, only its scheduler and you testing it in the console.

Deleting a job

Deleting a job retires its principal and removes that principal's role bindings, so nothing is left holding access. The run history is kept: "what did this do before we removed it" is exactly the question asked afterwards.

Next

On this page