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.
Basics
Section titled “Basics”This example adds a job to a web app that runs report.py every day at 9 a.m.
slug: my-appruntime: python-slimcommand: "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| Field | Meaning |
|---|---|
name | Job name, using lowercase letters, numbers, and hyphens |
schedule | Cron expression specifying the time or interval |
command | Command that performs one run of the task and exits |
timeout | Maximum duration per run in seconds. The job stops when this limit is reached |
enabled | Set to false to stop scheduled runs. Defaults to true |
description | Optional. 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.
Write a schedule
Section titled “Write a schedule”schedule uses a five-field cron expression to specify times or intervals.
| Schedule | Cron expression |
|---|---|
| Every day at 09:00 | 0 9 * * * |
| Weekdays at 09:00 | 0 9 * * 1-5 |
| Every hour, on the hour | 0 * * * * |
| Every 15 minutes (Plus / Team) | */15 * * * * |
| Every 5 minutes (Team) | */5 * * * * |
| Midnight on the first day of each month | 0 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.
Results and logs
Section titled “Results and logs”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.
Monthly run counts and total runtime
Section titled “Monthly run counts and total runtime”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.
Execution model
Section titled “Execution model”A scheduled job performs one unit of work and exits. It is not a way to keep an app running continuously.
| Situation | Behavior |
|---|---|
| Relationship to the web app | Runs in a separate environment; memory and local files are not shared |
| Saving data | Files 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 running | The next run of the same job is skipped. It is not queued for later |
| Run fails | No immediate automatic retry. The next scheduled run proceeds as usual |
| Time limit is reached | The 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.
Design rules
Section titled “Design rules”Make repeated runs safe
Section titled “Make repeated runs safe”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 plancrons: - 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.
Limit work per run
Section titled “Limit work per run”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.
Implementation examples
Section titled “Implementation examples”Morning report (Python)
Section titled “Morning report (Python)”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.
import osfrom datetime import datetime, timedeltafrom 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]}")Sync with an external API (Node.js)
Section titled “Sync with an external API (Node.js)”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.
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`);Slack notifications
Section titled “Slack notifications”This example assumes the notification logic is implemented in notify.py. Pass the webhook URL through a secret.
slug: daily-notifierruntime: python-slimdb: mode: nonesecrets: 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: 30If deployment is rejected
Section titled “If deployment is rejected”| Situation | What to do |
|---|---|
timeout exceeds 600 seconds or your plan’s limit | Reduce it to the limit or below, and split the task if necessary |
| Schedule is shorter than your plan’s minimum interval | Increase the interval |
| Number of jobs exceeds your plan’s limit | Combine 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 crons | Remove type for a jobs-only app. If adding work to a static site, also review whether it needs a server |
Retired workers: configuration remains | Remove it and move the work to crons or request handling as appropriate |
When scheduled jobs are not suitable
Section titled “When scheduled jobs are not suitable”- 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.