User guideAutomating
Schedule work with jobs
Declare jobs in toft.toml, put them on a schedule, run them from Toft, and start them from your web app.
A job is a command from your app’s deployment that runs as its own process, with its own timeout and memory limit. It is how you do anything on a schedule — a nightly report, an hourly sync — and anything too long for a web request, such as an export a person asks for.
Jobs are the one kind of automation in Toft. Each time a job starts, that is a run. A run happens once and ends in a state that says how it went.
Declare a job
Section titled “Declare a job”Jobs live in toft.toml, next to your code, so they
are reviewed, versioned and rolled back with it. Every deploy makes the app’s
jobs exactly what the file says.
[jobs.nightly-report]command = "python -m reports.nightly"schedule = "0 6 * * *" # optional; leave it out for an on-demand jobtimezone = "Europe/London" # optional; UTC without itoverlap = "skip" # optional; queue (default) or skipnotify = "failures" # optional; none (default), failures or alwaystimeout = "45m" # optional; your install's default without itmemory = 1024 # optional; MBexpect_within = "26h" # optional; tell the owners if it stops succeeding
[jobs.nightly-report.params.region]type = "string" # string, int, float or booldefault = "emea"choices = ["emea", "amer", "apac"]help = "Which region to report on"| Key | Notes |
|---|---|
command |
What to run, with your deployment’s own interpreter and packages. |
args |
Instead of command, for a Go app: arguments to the one binary your build produced. args = ["job", "nightly-report"] runs it as ./toft-app job nightly-report. |
schedule |
Five cron fields. Leave it out and the job runs only when something starts it. |
timezone |
An IANA zone such as Europe/London. The schedule follows the local clock through daylight saving changes and fires once per local time. |
overlap |
What a scheduled firing does if the last run is still going: queue waits its turn (the default), skip records a skipped run instead. |
notify |
Email the app’s owners on failures (when a run fails) or always (after every run). none by default. |
timeout |
"45m", "2h", or a number of seconds. Your administrator sets the default and the most a job may ask for. |
memory |
In MB. Your administrator sets the most a job may ask for. |
expect_within |
1h to 31d. If the job goes this long without a successful run, its owners are told once. A paused job is never overdue. |
A job may not ask for more time or memory than your install allows. A deploy
that does is refused with limit_exceeds_ceiling, and the message says what
you may ask for. Run toft check before you deploy to see each job’s limits
and where they came from.
Older keys are refused with removed_key and a hint naming what replaced them:
retries, viewer_runnable, enabled, and any [tasks] table.
Parameters
Section titled “Parameters”A parameter is declared under [jobs.<name>.params.<param>]:
| Key | Notes |
|---|---|
type |
string, int, float or bool. |
required |
true if every run must be given it. Not together with default. |
default |
Used when a run is not given it. |
choices |
The only values allowed. |
help |
Shown next to the field in Toft. |
Parameters are checked when a run starts, not part way through it. A missing
required one is refused with param_required, a wrong type with param_type
and a value outside choices with param_choice. Your job receives them typed:
--param rows=500 arrives as the number 500.
Parameters are a run’s only input. They are recorded on the run and shown in its history, so never put secrets in them, and keep them under 64 KB. For larger input, put a file in your app’s storage and pass its name.
Where a job runs
Section titled “Where a job runs”Each run is a separate process from your app’s current deployment, with the app’s environment variables. On an install with a cluster it gets its own container; otherwise it runs on the install’s own machine. Your web app does not have to be running, and a schedule does not keep it awake.
- A batch app, declared with
[batch]intoft.toml, has jobs and no web server. Opening its address shows each job’s schedule, next run and last outcome. - Static sites cannot have jobs. Rendered documents can.
- A locked app runs no jobs: schedules stop, queued runs are cancelled and running ones are stopped. See locked apps.
Run it from Toft
Section titled “Run it from Toft”The app’s Automation tab lists its jobs with their schedule in your local time, the next run, the last outcome, and the limits each run will get. Only the app’s owners and editors see it.
| Action | What it does |
|---|---|
| Run now | Opens a form built from the job’s parameters, with defaults filled in and choices as a list. It also shows the toft job run command that does the same thing. |
| Pause until next deploy | Stops the schedule. The next deploy turns it back on, because toft.toml says the job is scheduled. Resume schedule turns it back on sooner. |
| Run again | On a finished run: starts a new run with the same parameters, marked as a rerun of the first. |
| Cancel | On a run that is queued or running. |
New job does not create a job. It helps you write one: fill in the form and
it gives you the [jobs.<name>] block to paste into toft.toml, with the
schedule read back in plain words. Commit it and deploy.
From the command line:
toft job list # jobs, schedules, params and limitstoft job run nightly-report --param region=emeatoft job run nightly-report --no-wait # start it and return the run idtoft job runs nightly-report # recent runstoft job logs 3f9a2c1d --followtoft job rerun 3f9a2c1d # the same params againtoft job cancel 3f9a2c1dtoft job run waits and exits with the run’s outcome. See the command
reference for every flag.
The time a run is for
Section titled “The time a run is for”A scheduled run knows the time it was scheduled for, even if it started late:
TOFT_SCHEDULED_FOR, such as 2026-10-06T03:00:00+01:00, or
run.scheduled_for in the SDK. A run started by hand has none unless you name
one, so re-running Tuesday’s work on Thursday processes Tuesday:
toft job run nightly-report --for 2026-10-06T03:00:00+01:00In Toft, set Run for a past date in the Run now form. A rerun keeps the original’s time.
How a run ends
Section titled “How a run ends”| State | Means |
|---|---|
| Succeeded | The command exited 0. |
| Failed | It exited with another code, or could not start. |
| Timed out | It reached its timeout and was stopped. |
| Out of memory | It went over its memory limit and was stopped. |
| Cancelled | Someone cancelled it, or the app was locked. |
| Skipped | A scheduled firing that overlap = "skip" stood down. |
Every run that did not succeed says why in words, such as exit 3: connection refused, timed out after 45m or stopped at 1024 MB. What it printed up to
then stays in its log.
Nothing is retried for you. To try again, use Run again or toft job rerun;
the new run points at the one it repeats.
A failed run is recorded as an event on the app, and with notify set its
owners are emailed. A scheduled firing refused because the app already had too
many runs waiting is recorded as a warning.
Jobs and your web app
Section titled “Jobs and your web app”A web request is cut off after a few minutes, and a job can take much longer. So a page that starts a job should answer straight away and let the job run:
import toft
@app.post("/reports")def make_report(): me = toft.viewer(request.headers) # the person who clicked run = me.jobs.start("nightly-report", params={"region": "emea"}) return toft.jobs.respond(run)respond sends a browser to the run’s status page: a live page Toft serves
at your app’s own address, showing the job’s progress, any message the job
reports, and how it ended. You write no polling code and no template. A script
calling the same endpoint gets 202 Accepted and the run as JSON instead.
A run started for a person is shown on its status page to that person and to the app’s owners and editors.
Waiting for the run inside the request (run.wait()) is refused with
would_outlive_request, because the request would be cut off first. See
Starting jobs from your app for the rest.