Cron
Schedule actions on wall-clock time, including managed deploys and catch-up behavior.
Valet cron jobs target actions. A cron job is just a schedule plus an action reference and static args.
Create valet/cron.ts:
import { defineCron } from './_generated/valet/api'
import { api } from './_generated/valet/api'
export default defineCron({
jobs: [
{
name: 'daily-digest',
schedule: '0 15 * * *',
action: api.jobs.sendDailyDigest,
args: {},
},
],
})Target an action
Cron actions are ordinary defineAction(...) handlers:
// valet/digest.ts
import { defineAction, v } from './_generated/valet/api'
export const sendDailyDigest = defineAction({
args: {},
access: 'system',
handler: async (ctx) => {
if (ctx.invoker.type !== 'system') {
throw new Error('Expected system invoker')
}
return {
source: ctx.invoker.source,
scheduledAt: ctx.invoker.scheduledAt,
dispatchedAt: ctx.invoker.dispatchedAt,
}
},
})Cron-targeted actions should usually use access: 'system'.
Schedule format
Schedules use standard five-field cron syntax:
schedule: '*/5 * * * *' // every 5 minutes
schedule: '0 * * * *' // top of every hour
schedule: '0 15 * * *' // 15:00 UTC every dayValet validates the expression during codegen, so a bad schedule fails the build instead of silently deploying.
Catch-up behavior
By default, Valet keeps only the latest missed tick after an outage or wake gap:
{
name: 'refresh-search-index',
schedule: '*/5 * * * *',
action: api.jobs.refreshSearchIndex,
args: {},
missedTickPolicy: 'latest',
}If every missed tick matters, use 'all':
{
name: 'billing-rollup',
schedule: '0 * * * *',
action: api.jobs.billingRollup,
args: {},
missedTickPolicy: 'all',
maxCatchUpTicks: 48,
maxCatchUpAge: '48h',
}When a cron action runs, ctx.invoker can include:
scheduledAt: when the tick should have fireddispatchedAt: when Valet actually dispatched itcatchUpWindow:{ from, to }when Valet is replaying a catch-up window under'all'skippedTicks: how many older ticks were dropped by the catch-up policy or backlog caps
Example:
handler: async (ctx) => {
if (ctx.invoker.type !== 'system') throw new Error('system only')
return {
scheduledAt: ctx.invoker.scheduledAt,
dispatchedAt: ctx.invoker.dispatchedAt,
catchUpWindow: ctx.invoker.catchUpWindow,
skippedTicks: ctx.invoker.skippedTicks ?? 0,
}
}Deploying cron jobs
Cron manifests are generated during codegen and pushed automatically during deploy. On managed projects, deploy uses a staged activation flow:
- Codegen stages the new cron manifest in the control plane.
- The project server loads the generated cron manifest.
- The project server confirms readiness and activates the staged manifest.
That prevents the control plane from scheduling jobs before the matching code is live on the project server.
For direct/self-hosted project URLs, the manifest is pushed straight to the project server.
Manual trigger in development
Use the CLI to trigger a loaded cron job manually:
bunx valet-dev cron run daily-digestOptional flags:
bunx valet-dev cron run daily-digest --scheduled-at 1700000000000
bunx valet-dev cron run daily-digest --project-url http://localhost:3000Manual runs execute through the same server action path as real cron dispatches, but ctx.invoker.source is "admin" instead of "cron".
Observe recent cron runs
Recent runs are available from the project server admin surface:
curl "$VALET_PROJECT_URL/admin/cron/runs" \
-H "Authorization: Bearer $VALET_DEPLOY_KEY"This is the fastest way to inspect failures, stale-manifest skips, and recent dispatch history.