Skip to content
Console →
Website →
Asking an AI? Paste this URL https://keelson.dev/llms.txt

Scheduled jobs

Run tasks at set times or intervals, such as a report every morning or data synchronization every hour. These are called “scheduled jobs.” They run even when nobody is using the web app.

Tell your AI agent what should run and when, and it can configure crons in keelson.yaml.

Add a scheduled job that summarizes the previous day’s sales at 9 a.m. every morning in the workspace’s timezone. Make the results available in the logs.

This example adds a job to a web app that runs report.py every day at 9 a.m.

slug: my-app
runtime: python-slim
command: "python app.py"
db:
mode: libsql
crons:
- name: daily-report
description: "Email yesterday's sales report"
schedule: "0 9 * * *" # Every day at 09:00
command: "python report.py"
timeout: 120 # Limit each run to 120 seconds
FieldMeaning
nameJob name, using lowercase letters, numbers, and hyphens
scheduleCron expression specifying the time or interval
commandCommand that performs one run of the task and exits
timeoutMaximum duration per run in seconds. The job stops when this limit is reached
enabledSet to false to stop scheduled runs. Defaults to true
descriptionOptional. A one-line explanation of what the job does, shown in the Description column of the console’s Scheduled jobs tab. Leading and trailing whitespace is trimmed, and an empty value counts as unset. Up to 100 characters. It is display only and does not change what runs

name, schedule, and command are required. Set timeout between 1 and 600 seconds, within your plan’s limit. If omitted, it defaults to 300 seconds or your plan’s limit, whichever is shorter. Explicitly setting a value above the limit causes deployment to be rejected.

You can also create an app containing only scheduled jobs. Omit the top-level command in that case. Apps with only scheduled jobs do not count toward the concurrent app limit.

schedule uses a five-field cron expression to specify times or intervals.

ScheduleCron expression
Every day at 09:000 9 * * *
Weekdays at 09:000 9 * * 1-5
Every hour, on the hour0 * * * *
Every 15 minutes (Plus / Team)*/15 * * * *
Every 5 minutes (Team)*/5 * * * *
Midnight on the first day of each month0 0 1 * *

Schedules are evaluated in the workspace’s timezone. It is set from the browser when the workspace is created and cannot be changed later. A run may start later than its scheduled time.

The minimum interval is 60 minutes on Starter, 15 minutes on Plus, and 5 minutes on Team. Shorter schedules are rejected during deployment. Running every minute is not supported.

Cron expression fields
┌───────────── Minute (0–59)
│ ┌─────────── Hour (0–23)
│ │ ┌───────── Day of month (1–31)
│ │ │ ┌─────── Month (1–12)
│ │ │ │ ┌───── Day of week (0–6, 0 = Sunday)
* * * * *

* means every value. For example, 0 9 * * * means 09:00 on every day of every month, regardless of the day of the week.

On the app’s console page, you can see each job’s run history, success or failure, and logs. Use Run now to test a job without waiting for the next scheduled time.

If a run fails, check its logs, required secrets and configuration, and whether it can finish within timeout. You can share the logs with your AI agent and ask it to investigate and fix the problem.

Standard output and standard error are each recorded up to 256 KiB per run. Output beyond that is truncated.

Only runs that actually start are counted. Successful runs, failed runs, and Run now executions count; skipped runs do not. Total runtime is elapsed time, including time spent waiting for external APIs. If a run cannot be measured because of a platform failure, it consumes one run but does not count toward total runtime. Once either monthly limit is reached, remaining runs that month are skipped. A run that has already started is allowed to finish, and the allowances reset the following month.

The maximum number of jobs at the minimum interval does not fit within the monthly limits. The job count, minimum interval, per-run duration, monthly run count, and total runtime are independent limits.

See Plans and limits for the limits for each plan.

A scheduled job performs one unit of work and exits. It is not a way to keep an app running continuously.

SituationBehavior
Relationship to the web appRuns in a separate environment; memory and local files are not shared
Saving dataFiles inside the execution environment do not carry over to the next run. Save persistent data in Managed SQLite or with the Files / Media SDK
Previous run is still runningThe next run of the same job is skipped. It is not queued for later
Run failsNo immediate automatic retry. The next scheduled run proceeds as usual
Time limit is reachedThe job stops, even if it is partway through its work

Timers and schedulers inside an app do not run while the app is stopped. Use crons for work that must run on a schedule. Background work after returning a response is also not guaranteed to finish. Complete it during the request, or enqueue work that continues after the response as a background task.

See How apps run.

Prevent duplicate records or deliveries when an interrupted task is retried or someone uses Run now to repeat it. This property is called “idempotency.”

For example, if reports.date has a primary key or UNIQUE constraint, you can update an existing record for the same date.

conn.execute("""
INSERT INTO reports (date, total) VALUES (?, ?)
ON CONFLICT(date) DO UPDATE SET total = excluded.total
""", (today, total))
conn.commit()

Process accepted work later without background tasks

Section titled “Process accepted work later without background tasks”

To run accepted work right after the request, use background tasks first. If you build it without background tasks, or a wait until the next run is acceptable, use the method below.

To collect work in the web app and process it later, save pending work in Managed SQLite. A scheduled job reads the pending records, processes them, and records completion.

# Run every 5 minutes on the Team plan
crons:
- name: process-pending
schedule: "*/5 * * * *"
command: "python process_pending.py"
timeout: 120
  • Allow for the delay before work starts. Work waits until the next run, so this is unsuitable for tasks that must start immediately. Tell users about the wait.
  • Plan for repeated processing. An action such as sending email may succeed, but the job may stop before recording completion in the database.
  • Record failure counts and reasons. Set a retry limit and decide what to do with repeatedly failing records. Keelson does not automatically retry or isolate individual pending records.

Complete short tasks within the request before returning the response.

When processing many records, cap the number handled in each run and leave the rest for the next one. Split work that exceeds the execution time limit across multiple runs.

When calling external APIs, also respect that service’s rate limits. Increase the interval or reduce the number of calls per run.

This example assumes sales and reports tables already exist and reports.date has a primary key or UNIQUE constraint. Add libsql to your dependencies.

The example summarizes the previous day in Japan time. It assumes sales.date stores dates in YYYY-MM-DD format using the same timezone. Change Asia/Tokyo to the timezone used for your reports. The workspace schedule timezone does not automatically change the timezone your code uses to calculate dates.

report.py
import os
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import libsql
conn = libsql.connect(
database=os.environ["KEELSON_DB_URL"],
auth_token=os.environ["KEELSON_DB_AUTH_TOKEN"],
)
report_date = (datetime.now(ZoneInfo("Asia/Tokyo")).date() - timedelta(days=1)).isoformat()
row = conn.execute(
"SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM sales WHERE date = ?", (report_date,)
).fetchone()
conn.execute(
"INSERT INTO reports (date, count, total) VALUES (?, ?, ?) "
"ON CONFLICT(date) DO UPDATE SET count = excluded.count, total = excluded.total",
(report_date, row[0], row[1]),
)
conn.commit()
print(f"Report complete: {report_date}, {row[0]} records / {row[1]}")

Add @libsql/client to your dependencies and set the SYNC_API_URL environment variable. This example assumes a synced_items table exists with a primary key or UNIQUE constraint on id, and that the API returns an array of objects with an id.

sync.js
import { createClient } from "@libsql/client";
const db = createClient({
url: process.env.KEELSON_DB_URL,
authToken: process.env.KEELSON_DB_AUTH_TOKEN,
});
const res = await fetch(process.env.SYNC_API_URL);
if (!res.ok) throw new Error(`Sync API error: ${res.status}`);
const items = await res.json();
for (const item of items) {
await db.execute({
sql: `INSERT INTO synced_items (id, data) VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
args: [item.id, JSON.stringify(item)],
});
}
console.log(`Sync complete: ${items.length} records`);

This example assumes the notification logic is implemented in notify.py. Pass the webhook URL through a secret.

slug: daily-notifier
runtime: python-slim
db:
mode: none
secrets:
items:
- name: SLACK_WEBHOOK_URL
description: "Slack Incoming Webhook for notifications"
required:
- all_of: [SLACK_WEBHOOK_URL]
crons:
- name: daily-notify
schedule: "0 9 * * 1-5"
command: "python notify.py"
timeout: 30
SituationWhat to do
timeout exceeds 600 seconds or your plan’s limitReduce it to the limit or below, and split the task if necessary
Schedule is shorter than your plan’s minimum intervalIncrease the interval
Number of jobs exceeds your plan’s limitCombine tasks or remove unused jobs
description is longer than 100 characters (cron_description_too_long)Shorten it to one line of 100 characters or fewer. It is not truncated automatically
type: web is combined with cronsRemove type for a jobs-only app. If adding work to a static site, also review whether it needs a server
Retired workers: configuration remainsRemove it and move the work to crons or request handling as appropriate
  • Work that must start immediately after an event; a scheduled job waits until its next run. Use background tasks instead.
  • Work that cannot be split and exceeds the per-run time limit.
  • Workflow orchestration, such as starting one job only after another finishes.