Scheduled Jobs (Cron)
What you can do with Scheduled Jobs
Section titled “What you can do with Scheduled Jobs”Keelson can run code automatically on a schedule as well as serve an application.
You manage jobs from the same codebase and the same keelson.yaml as your web
application, making them useful for automating routine business operations.
Common use cases include:
- generating a morning report and saving sales or KPI totals to a database;
- synchronizing an external API with your database every hour;
- sending daily Slack reminders about overdue tasks or pending approvals;
- importing accepted CSV data in a nightly batch;
- periodically deleting old records; and
- draining work that the web application recorded for later processing.
The crons section is the only background execution mechanism available to an
application. The former workers: declaration has been retired, and Keelson does
not provide an API for submitting arbitrary asynchronous tasks.
How Apps Run explains when server-side code
is guaranteed to run and which common patterns that rules out.
Scheduled Jobs do not consume the plan quota for concurrently available web applications. They are counted separately from web applications.
How Scheduled Jobs work
Section titled “How Scheduled Jobs work”- Define a job under
cronsinkeelson.yaml. - Deploy the application, and Keelson registers the scheduled job.
- Keelson runs the configured command according to its schedule.
- Keelson records the result and logs for each execution.
Relationship to the web application
Section titled “Relationship to the web application”A job starts from the same codebase and image as the web application, but it runs
in a separate instance. It does not share memory or local disk with the web
application. Keelson does not provide a writable /data directory; use /tmp
only for temporary files that may be discarded when the execution ends.
| Configuration | Behavior |
|---|---|
Cron only, with no top-level command | Runs as a job-only application and does not consume the concurrent web-app quota |
Web application plus cron, with a top-level command | Runs the web application and each job in separate instances; shared state belongs in a durable store |
The job definitions use the same format in both configurations.
Tracking success and failure
Section titled “Tracking success and failure”- The dashboard shows whether each execution succeeded or failed.
- Standard output and standard error are recorded as logs.
- Usage includes runs that actually execute, whether they succeed, fail, time out, or are triggered manually. An occurrence skipped before the container starts does not consume the monthly execution quota.
- A failed cron execution is not automatically retried. It waits for the next scheduled occurrence.
Basic configuration
Section titled “Basic configuration”Define jobs in the crons section of keelson.yaml:
slug: my-appruntime: python-slimdb: mode: none
crons: - name: daily-report schedule: "0 9 * * *" command: "python report.py" timeout: 120Fields
Section titled “Fields”| Field | Required | Default | Description |
|---|---|---|---|
name | Yes | — | Job name: 1–63 lowercase letters, numbers, and hyphens |
schedule | Yes | — | Five-field cron expression |
command | Yes | — | Command to execute |
timeout | No | 300-second schema default | Timeout from 1–600 seconds; the effective default is capped at the plan maximum |
Defining multiple jobs
Section titled “Defining multiple jobs”The number of jobs allowed in one application depends on its plan: Starter 3, Plus 5, and Team 10.
crons: - name: hourly-sync schedule: "0 * * * *" command: "python sync.py"
- name: daily-cleanup schedule: "0 3 * * *" command: "python cleanup.py" timeout: 60
- name: weekly-report schedule: "0 9 * * 1" command: "python weekly_report.py" timeout: 600The 600-second timeout in the last example requires the Team plan.
Environment variables
Section titled “Environment variables”Environment variables declared in the top-level env section are also
available while a job runs.
db: mode: libsqlsecrets: items: - name: SLACK_WEBHOOK_URL description: "Slack incoming webhook URL" required: - all_of: [SLACK_WEBHOOK_URL]
crons: - name: notify schedule: "0 9 * * *" command: "python notify.py"Writing cron expressions
Section titled “Writing cron expressions”The schedule is a standard five-field cron expression:
┌───────────── minute (0-59)│ ┌─────────── hour (0-23)│ │ ┌───────── day of month (1-31)│ │ │ ┌─────── month (1-12)│ │ │ │ ┌───── day of week (0-6, 0 = Sunday)│ │ │ │ │* * * * *Common schedules
Section titled “Common schedules”| Goal | Cron expression | Meaning | Minimum plan |
|---|---|---|---|
| Every day at 9:00 | 0 9 * * * | Run at 9:00 each day | Starter |
| Every 30 minutes | */30 * * * * | Run at minute 0 and 30 of every hour | Plus |
| Every hour | 0 * * * * | Run at minute 0 of every hour | Starter |
| Every 10 minutes | */10 * * * * | Run at ten-minute intervals | Team |
| Weekdays at 9:00 | 0 9 * * 1-5 | Run Monday through Friday at 9:00 | Starter |
| First day of each month | 0 0 1 * * | Run at midnight on the first day | Starter |
| Every day at 3:00 | 0 3 * * * | A common nightly batch schedule | Starter |
| Every minute | * * * * * | Syntax example only; no current plan permits this interval | Not available |
Cron schedules are evaluated in the workspace time zone, which is detected
automatically from the browser when the workspace is created. For example,
0 9 * * * runs at 09:00 in the workspace time zone.
Practical examples
Section titled “Practical examples”Aggregate and save sales data every morning
Section titled “Aggregate and save sales data every morning”- Input: sales records in Managed SQLite (
db.mode: libsql) - Processing: aggregate the day’s records and calculate totals
- Output: save the result in a
reportstable
crons: - name: daily-sales schedule: "0 8 * * *" command: "python aggregate_sales.py" timeout: 120Synchronize data from an external API every hour
Section titled “Synchronize data from an external API every hour”- Input: an external service’s REST API
- Processing: fetch the latest data and apply changes to Managed SQLite
- Output: updated rows in the relevant table
crons: - name: hourly-sync schedule: "0 * * * *" command: "python sync_from_api.py" timeout: 180Delete expired records every day
Section titled “Delete expired records every day”- Input: database records
- Processing: delete rows whose
expired_atvalue is in the past - Output: write the number of deleted rows to the log
crons: - name: cleanup-expired schedule: "0 2 * * *" command: "python cleanup_expired.py" timeout: 60Generate a weekly PDF report
Section titled “Generate a weekly PDF report”- Input: weekly data from the database
- Processing: render a report from a template
- Output: save the report through an object-storage API
crons: - name: weekly-report schedule: "0 9 * * 1" command: "python generate_weekly_report.py" timeout: 300The 300-second timeout requires a Plus or Team plan. On Starter, use at most 180 seconds or split the report into smaller batches.
Operating jobs safely
Section titled “Operating jobs safely”Make each job idempotent
Section titled “Make each job idempotent”A job runs repeatedly and may be interrupted. Design it so that running the same operation again does not create duplicate data or corrupt existing state.
# Avoid an unconditional INSERT that creates a duplicate on every run.db.execute("INSERT INTO reports (date, total) VALUES (?, ?)", (today, total))
# Prefer an UPSERT that safely replaces the row for the same date.db.execute(""" INSERT INTO reports (date, total) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET total = excluded.total""", (today, total))Set a bounded timeout
Section titled “Set a bounded timeout”The schema default is 300 seconds, but the effective timeout is the lower of
that value and the plan maximum. For example, omitting timeout on Starter gives
an effective timeout of 180 seconds. An explicitly configured value must be in
the absolute 1–600 second range and at or below the plan maximum; a larger value
is rejected during deployment.
If work cannot finish in ten minutes, limit the number of records handled in one run and let later scheduled executions continue with the remainder.
Plan for failure without automatic retries
Section titled “Plan for failure without automatic retries”Keelson does not automatically rerun a failed job. An idempotent job can recover naturally on its next scheduled execution. For critical jobs, also record a failure signal or send an alert to a service such as Slack.
Respect external API rate limits
Section titled “Respect external API rate limits”When a job calls an external API, account for that service’s rate limits. A hypothetical every-minute schedule would quickly exceed many providers’ allowances, although current Keelson plans do not permit that interval.
Coordinate concurrent Managed SQLite writes
Section titled “Coordinate concurrent Managed SQLite writes”The web process and every cron use the same Managed SQLite database. A cron can
write while the web process is writing, and different crons can overlap when
their execution times cross. The libSQL primary accepts one writer at a time, so
write contention can fail an execution with errors such as SQLITE_BUSY or
TRANSACTION_TIMEOUT.
Keep write transactions short and split large updates into bounded batches. If several processes update the same rows, serialize those writes in application logic or use a small, bounded retry with backoff for transient contention. Make the operation idempotent before retrying it; Keelson does not automatically retry a failed cron execution.
Keep shared state out of local files
Section titled “Keep shared state out of local files”The web process and cron process do not share a local SQLite file or filesystem.
Use Managed SQLite for shared relational state and the Files SDK for durable
files. Files written to /tmp are ephemeral. Keelson does not provide a
writable /data directory.
Logs and debugging
Section titled “Logs and debugging”Inspecting execution logs
Section titled “Inspecting execution logs”Standard output, such as Python print calls or JavaScript console.log calls,
and standard error are captured separately. Each stream is limited to 256 KiB.
If a stream exceeds that limit, Keelson discards the excess and appends an
…[output truncated at 262144 bytes]… marker to the captured text. Output near
the end of a very verbose run, including a final exception or summary, may
therefore be absent.
Open the dashboard to inspect the execution history and captured logs for an
individual job.
Log useful progress information and record counts so a failure can be located without reproducing the entire run.
import datetime
print(f"[{datetime.datetime.now()}] job started")# ... process records ...print(f"processed records: {count}")print(f"[{datetime.datetime.now()}] job completed")What to check after a failure
Section titled “What to check after a failure”- Read the execution log for an error message and stack trace.
- Check environment variables such as API credentials and database URLs.
- Check the timeout and confirm the command finishes within it.
- Reproduce locally by running the same command in a comparable environment.
Asking an AI agent to diagnose a log
Section titled “Asking an AI agent to diagnose a log”You can give the Keelson job log directly to an AI agent and ask it to identify the cause, update the code, and redeploy the application.
Review this job error log, identify the cause, and fix the application.
Combining a web application with jobs
Section titled “Combining a web application with jobs”Web applications, scheduled jobs, and durable data can work together in one Keelson application. The database, rather than process memory or local disk, is the handoff point between the web and cron instances.
Pattern: admin interface and nightly batch
Section titled “Pattern: admin interface and nightly batch”Use the web application to enter settings and data, and let a nightly job handle the heavier aggregation work.
slug: sales-toolruntime: python-slimcommand: "python app.py"db: mode: libsqlenv: PYTHONUNBUFFERED: "1"
crons: - name: nightly-aggregate schedule: "0 2 * * *" command: "python aggregate.py" timeout: 300The 300-second timeout requires a Plus or Team plan. Starter accepts a maximum of 180 seconds.
Declare Python dependencies in requirements.txt; Keelson installs them during
the build. The web application must listen on the PORT environment variable
injected by Keelson rather than declaring its own value.
- During the day, users enter sales data through the admin interface.
- At night, the job aggregates the data and updates report rows.
- The next morning, the interface reads and displays those rows.
Pattern: process uploaded CSV records periodically
Section titled “Pattern: process uploaded CSV records periodically”Record each accepted upload as a pending database row. A job can claim those
rows periodically, process them, and write back the result.
crons: - name: process-csv schedule: "*/30 * * * *" command: "python process_inbox.py" timeout: 180This 30-minute schedule requires a Plus or Team plan. Starter jobs must be scheduled at least 60 minutes apart.
- The web application creates a
pendingrow for the accepted CSV. - Every 30 minutes, the job processes pending rows and updates their
status. - The web application displays or downloads the completed result.
Store the uploaded content with the Files SDK when the job needs the original file. Keep its key in Managed SQLite; do not pass it through a local directory.
Pattern: daily AI summary
Section titled “Pattern: daily AI summary”crons: - name: daily-summary schedule: "0 7 * * *" command: "python generate_summary.py" timeout: 600This 600-second timeout requires the Team plan.
- At 7:00 each morning, the job summarizes the previous day’s data with an AI API.
- The job stores the summary in Managed SQLite.
- The web application displays summaries by date.
Common mistakes
Section titled “Common mistakes”Reversing the cron fields
Section titled “Reversing the cron fields”The field order is minute, hour, day of month, month, and day of week. To run at
9:00, use 0 9 * * *. The reversed 9 0 * * * runs at 00:09.
# Wrong for 9:00: this runs at 00:09.schedule: "9 0 * * *"
# Correct: this runs at 09:00.schedule: "0 9 * * *"Treating temporary files as durable state
Section titled “Treating temporary files as durable state”Output written to /tmp is not available to a later execution. A cron also runs
separately from the web instance, and Keelson does not provide a writable
/data directory. Put durable relational data in db.mode: libsql, and use an
appropriate Keelson SDK for durable file content: Files for private files or
Media for content served to app members.
Missing production credentials
Section titled “Missing production credentials”A command that works locally can fail on Keelson if its API key exists only in your local environment. Configure environment variables and secrets for the deployed application instead of embedding credentials in source code.
Exceeding an external API rate limit
Section titled “Exceeding an external API rate limit”A job that sends many requests every minute can be rejected by the external service. Increase the interval, batch the work, or limit requests within a run.
Exceeding the monthly execution quota
Section titled “Exceeding the monthly execution quota”Scheduled Jobs have monthly execution quotas: Starter 1,000, Plus 5,000, and Team 15,000. After the quota is reached, remaining occurrences for the month are skipped. Only runs that actually execute count toward usage; skips caused by the quota, a previous active run, a disabled cron, or a stopped application do not increase the used count. When a cron or application is disabled, the scheduler may be removed entirely, so no skip record is guaranteed for each tick.
Hypothetically, an every-minute schedule would have about 43,200 occurrences in
a 30-day month. No current plan permits that schedule, even for testing: the
shortest available interval is five minutes on the Team plan. Keelson
validates the plan’s minimum interval during deployment and rejects * * * * *.
Complete samples
Section titled “Complete samples”Minimal cron-only application
Section titled “Minimal cron-only application”This configuration runs a job without a web application:
slug: my-cronruntime: python-slimdb: mode: noneenv: PYTHONUNBUFFERED: "1"
crons: - name: heartbeat schedule: "0 * * * *" command: "python heartbeat.py" timeout: 30heartbeat.py:
import datetime
print(f"OK: {datetime.datetime.now()}")This hourly schedule is accepted by every current plan.
Python: aggregate sales data in Managed SQLite
Section titled “Python: aggregate sales data in Managed SQLite”This cron-only sample uses Managed SQLite. Add the following files alongside
aggregate_sales.py.
keelson.yaml:
slug: daily-salesruntime: python-slimdb: mode: libsqlenv: PYTHONUNBUFFERED: "1"
crons: - name: daily-sales schedule: "0 8 * * *" command: "python aggregate_sales.py" timeout: 120requirements.txt:
libsql==0.1.11Keelson injects KEELSON_DB_URL and KEELSON_DB_AUTH_TOKEN because the deploy
declares db.mode: libsql. The example expects the web application or a prior
migration to have created and populated sales, and to have created reports
with a unique date column.
aggregate_sales.py:
import datetimeimport jsonimport os
import libsql
conn = libsql.connect( database=os.environ["KEELSON_DB_URL"], auth_token=os.environ["KEELSON_DB_AUTH_TOKEN"],)today = datetime.date.today().isoformat()
row = conn.execute( "SELECT COUNT(*), SUM(amount) FROM sales WHERE date = ?", (today,)).fetchone()
report = {"date": today, "count": row[0], "total": row[1] or 0}conn.execute( """ INSERT INTO reports (date, payload) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET payload = excluded.payload """, (today, json.dumps(report)),)conn.commit()print(f"aggregation complete: {report}")Node.js: synchronize an external API
Section titled “Node.js: synchronize an external API”This cron-only sample needs these files alongside sync.mjs.
keelson.yaml:
slug: catalog-syncruntime: node-slimdb: mode: libsqlenv: SYNC_API_URL: "https://api.example.com/items"
crons: - name: hourly-sync schedule: "0 * * * *" command: "node sync.mjs" timeout: 180Replace the example SYNC_API_URL with the HTTPS endpoint to synchronize. If
the endpoint needs a credential, declare the credential under secrets and set
its value separately instead of committing it under env.
package.json:
{ "private": true, "dependencies": { "@libsql/client": "0.17.4" }}Run npm install --package-lock-only after creating package.json, and commit
the generated package-lock.json so the build resolves the same dependency
version. Keelson injects the two database environment variables for
db.mode: libsql.
sync.mjs:
import { createClient } from "@libsql/client";
const db = createClient({ url: process.env.KEELSON_DB_URL, authToken: process.env.KEELSON_DB_AUTH_TOKEN,});const API_URL = process.env.SYNC_API_URL;
async function sync() { const res = await fetch(API_URL); const items = await res.json();
await db.execute(` CREATE TABLE IF NOT EXISTS synced_items ( id TEXT PRIMARY KEY, data TEXT, synced_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `);
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, synced_at = CURRENT_TIMESTAMP`, args: [item.id, JSON.stringify(item)], }); }
console.log(`synchronized ${items.length} records`);}
sync().catch((err) => { console.error("synchronization failed:", err); process.exit(1);});Python: send a Slack notification
Section titled “Python: send a Slack notification”import datetimeimport jsonimport osimport urllib.request
slack_webhook = os.environ["SLACK_WEBHOOK_URL"]message = {"text": f"Daily report is ready: {datetime.date.today()}"}
request = urllib.request.Request( slack_webhook, data=json.dumps(message).encode(), headers={"Content-Type": "application/json"},)urllib.request.urlopen(request)print("Slack notification sent")keelson.yaml:
slug: daily-notifierruntime: python-slimdb: mode: noneenv: PYTHONUNBUFFERED: "1"secrets: items: - name: SLACK_WEBHOOK_URL description: "Slack incoming webhook URL" required: - all_of: [SLACK_WEBHOOK_URL]
crons: - name: daily-notify schedule: "0 9 * * 1-5" command: "python notify.py" timeout: 30Declare only the secret name in keelson.yaml; never commit its value.
The CLI can set a secret only after the application exists. For an existing
application, set the value and immediately redeploy it with --apply:
printf '%s' "$SLACK_WEBHOOK_URL" | keelson secrets set SLACK_WEBHOOK_URL --applyFor a new application, run keelson deploy --new once. The required-secret
check blocks that first deployment, but the application remains created. Store
the value without --apply, because there is no completed deployment to
redeploy yet, and then deploy the same source tree again without --new:
printf '%s' "$SLACK_WEBHOOK_URL" | keelson secrets set SLACK_WEBHOOK_URL --app daily-notifierkeelson deployDraining queued work periodically
Section titled “Draining queued work periodically”Use crons not only for a particular time of day, but also for work that should
be drained every few minutes. Define one short-interval schedule and let each
execution process a bounded number of pending records before exiting.
slug: mailerruntime: python-slimcommand: "python app.py" # Web: create a pending row
db: mode: libsql # Durable state shared by web and cron
env: PYTHONUNBUFFERED: "1"
crons: - name: drain schedule: "*/5 * * * *" # Team: drain every five minutes command: "python drain.py" # Process pending rows and exit 0 timeout: 120The five-minute schedule requires the Team plan. On Plus, use at least 15 minutes; on Starter, use at least 60 minutes.
Three rules for a drain job
Section titled “Three rules for a drain job”- Queue work in database rows and drain it idempotently. The web side writes
a
pendingrow to Managed SQLite. The cron claims pending rows, performs the work, and updatesprocessed_atorstatus. Make repeating any item harmless, because a timeout can leave it for the next run. - Do not use an in-process scheduler. APScheduler,
node-cron, FastAPIBackgroundTasks,setInterval, and similar mechanisms assume a resident process. Keelson applications can scale to zero, so move periodic work into acronsdeclaration. - Use only durable services for shared state. Put durable relational state in Managed SQLite and private durable files in the Files SDK. A file written to a cron’s local disk is discarded and is never visible to the web application.
When one run might take too long
Section titled “When one run might take too long”A run is stopped after at most 600 seconds. When the amount of work is variable, set a maximum batch size and leave the rest for the next schedule. If the previous execution is still active, the overlapping occurrence is skipped.
Plan limits
Section titled “Plan limits”Limits for crons vary by plan.
| Limit | Starter | Plus | Team |
|---|---|---|---|
| Cron entries per app | 3 | 5 | 10 |
| Minimum cron interval | 60 min | 15 min | 5 min |
Execution timeout maximum | 3 min | 5 min | 10 min |
| Scheduled Jobs executions per month | 1,000 | 5,000 | 15,000 |
- Every
timeoutalso has an absolute 600-second maximum. The lower of the plan limit and the absolute maximum applies. - A schedule more frequent than the plan’s minimum interval is rejected.
- The monthly quota counts runs that actually execute, not skipped occurrences. After it is reached, remaining occurrences are skipped without increasing the used count until the quota resets the following month.
When deployment rejects a job definition
Section titled “When deployment rejects a job definition”Keelson rejects a deployment when its crons configuration does not fit the
execution model or plan. The structured error includes a code and a hint
that you can pass to an AI agent along with the configuration.
| Situation | Resolution |
|---|---|
workers: is declared (workers_not_supported) | Delete the block and rewrite the operation as a crons entry |
timeout is above 600 seconds | Reduce it to 600 or less |
timeout is above the plan maximum | Reduce it to the plan limit or upgrade the plan |
| The schedule is more frequent than the plan permits | Increase the interval or upgrade the plan |
| The application defines too many cron entries | Combine jobs, remove entries, or upgrade the plan |
When Scheduled Jobs are a good fit
Section titled “When Scheduled Jobs are a good fit”- Routine business automation: daily or hourly aggregation, notifications, data synchronization, and cleanup.
- Workflows shared with a web application: interactive data entry during the day followed by scheduled batch processing.
- Periodic external integration: fetching from APIs or sending scheduled webhooks.
- Regular data maintenance: removing old records and creating reports.
When Scheduled Jobs are not a good fit
Section titled “When Scheduled Jobs are not a good fit”- Immediate event-driven processing: finish small work within the request or use an external task service when an event must trigger work immediately.
- Jobs longer than ten minutes: split the work into bounded batches handled over multiple executions.
- Arbitrary asynchronous tasks submitted by the application: Keelson does not provide a task-submission API in the initial release.
- Complex job chains: use a dedicated workflow engine when jobs require dependencies, branching, or orchestration.
- Second-level precision: five-field cron expressions schedule by the minute.
Frequently asked questions
Section titled “Frequently asked questions”Are failed jobs retried automatically?
Section titled “Are failed jobs retried automatically?”No. The job runs again at its next scheduled occurrence. Make the command idempotent so the next execution can safely recover unfinished work.
Can I run only a job, without a web application?
Section titled “Can I run only a job, without a web application?”Yes. Omit the top-level command and define one or more crons entries to
deploy a job-only application.
Where can I see execution results?
Section titled “Where can I see execution results?”The dashboard shows each job’s execution history, success or failure status, and logs.
What happens when the monthly quota is reached?
Section titled “What happens when the monthly quota is reached?”Remaining occurrences in that month are skipped. The quota resets the next month. Limits are Starter 1,000, Plus 5,000, and Team 15,000 occurrences per month.
Which timezone does a schedule use?
Section titled “Which timezone does a schedule use?”Cron schedules are evaluated in the workspace time zone. This setting is detected automatically from the browser when the workspace is created.
Example prompts for an AI agent
Section titled “Example prompts for an AI agent”Add a job that aggregates sales data every morning at 9:00. Define it in the
cronssection ofkeelson.yaml.
Create a batch process that fetches an external API every 30 minutes and synchronizes the result to Managed SQLite. This schedule requires Plus or a higher plan.
Add a cleanup job that deletes old records every day at 3:00.