Scheduled Flows
Flows can run on a schedule for automated background processing. Use scheduled flows for tasks like daily reports, inventory checks, syncing with external systems, or any recurring work that benefits from durable execution.
Defining a schedule
Add the @schedule attribute to your flow definition:
flow DailyInventoryCheck {
@schedule("every day at 6am")
@permission(roles: [System])
}This flow runs every day at 6am. Since it's automated, there's no user to authenticate, so you'll typically use a system role for permissions.
Scheduled flows cannot have inputs. If you need to pass data into a scheduled flow, read it from the database or environment variables instead.
Schedule syntax
Keel supports two formats: human-readable expressions and cron syntax.
Human-readable
Start with every followed by an interval or day and time:
@schedule("every 30 minutes")
@schedule("every 2 hours from 9am to 5pm")
@schedule("every monday at 9am")
@schedule("every weekday at 8am")
@schedule("every tuesday and thursday at 9am, 12pm, and 5pm")Supported formats:
every N minutesevery N hoursevery N minutes from T to Tevery N hours from T to Tevery D at T
Where N is an integer, T is a 12-hour time, and D is a day of the week. D can also be day for everyday or weekday for Monday-Friday.
In the format every D at T both D and T can be a list of values, for example every monday and tuesday at 9am, 12pm, and 3pm. The values must be comma-separated but the word and is also allowed to improve readability.
Cron syntax
For complex schedules, use standard cron with five fields (minute, hour, day-of-month, month, day-of-week):
@schedule("0 9 * * 1") // Every Monday at 9am
@schedule("*/15 * * * *") // Every 15 minutes
@schedule("0 6,18 * * *") // 6am and 6pm daily
@schedule("0 9 1 * *") // First of every month at 9am| Human-readable | Cron |
|---|---|
every 10 minutes | */10 * * * * |
every 30 minutes from 10am to 2pm | */30 10-14 * * * |
every 2 hours | 0 */2 * * * |
every monday at 9am | 0 9 * * 1 |
every weekday at 8am | 0 8 * * 1-5 |
Use crontab.guru (opens in a new tab) to build and test cron expressions.
When using time-based expressions such as every day at 9am, execution times are determined by the project's configured time zone:
- London: Europe/London
- Frankfurt: Europe/Berlin
- Singapore: Asia/Singapore
Validation
Keel checks your flow's schedule at build time. Examples of validation errors:
- Having more than one
@scheduleattribute per flow definition - A schedule that doesn't divide evenly into the time-unit (e.g.
every 5 hoursis invalid because 24 doesn't divide evenly by 5) - Invalid cron syntax (e.g.
0 9am * * *results in the errorinvalid value '9am' for the hours field)
Implementing scheduled flows
Scheduled flows work like regular flows but run automatically without user interaction. Wrap your work in steps for durable execution.
import { DailyInventoryCheck, models } from "@teamkeel/sdk";
export default DailyInventoryCheck(async (ctx) => {
// Get all items below reorder point
const lowStock = await ctx.step("find low stock", async () => {
return await models.stockItem.findMany({
where: {
quantity: { lessThan: models.stockItem.reorderPoint }
}
});
});
if (lowStock.length === 0) {
return { itemsChecked: 0, alertsSent: 0 };
}
// Create purchase order suggestions
for (const item of lowStock) {
await ctx.step(`create-suggestion-${item.id}`, async () => {
await models.purchaseOrderSuggestion.create({
stockItemId: item.id,
suggestedQuantity: item.reorderQuantity,
reason: "Below reorder point",
});
});
}
// Send notification
await ctx.step("notify", async () => {
await sendSlackMessage({
channel: "#inventory",
text: `${lowStock.length} items below reorder point`,
});
});
return {
itemsChecked: lowStock.length,
alertsSent: lowStock.length,
};
});Each step runs durably. If the flow fails partway through, completed steps won't re-run when the flow retries.
Use cases
Inventory management
Check stock levels, create reorder suggestions, sync with suppliers:
export default SyncSupplierPrices(async (ctx) => {
const suppliers = await models.supplier.findMany({ where: { active: true } });
for (const supplier of suppliers) {
await ctx.step(`sync-${supplier.id}`, async () => {
const prices = await supplierApi.getPrices(supplier.apiKey);
for (const price of prices) {
await models.supplierPrice.upsert({
where: { supplierId: supplier.id, sku: price.sku },
create: { supplierId: supplier.id, sku: price.sku, price: price.price },
update: { price: price.price },
});
}
});
}
});Order processing
Process pending orders, check payment status, update shipment tracking:
export default UpdateTrackingInfo(async (ctx) => {
const shipped = await models.order.findMany({
where: { status: "Shipped", deliveredAt: null }
});
for (const order of shipped) {
await ctx.step(`track-${order.id}`, async () => {
const status = await carrierApi.getStatus(order.trackingNumber);
if (status.delivered) {
await models.order.update({
where: { id: order.id },
data: { status: "Delivered", deliveredAt: status.deliveredAt }
});
}
});
}
});Reporting
Generate daily reports, calculate metrics, send summaries:
export default DailySalesReport(async (ctx) => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const sales = await ctx.step("calculate sales", async () => {
const orders = await models.order.findMany({
where: {
createdAt: { greaterThanOrEquals: yesterday },
status: { notEquals: "Cancelled" }
}
});
return {
count: orders.length,
total: orders.reduce((sum, o) => sum + o.total, 0),
};
});
await ctx.step("send report", async () => {
await sendEmail({
to: ctx.env.SALES_REPORT_EMAIL,
subject: `Sales Report - ${yesterday.toDateString()}`,
body: `Orders: ${sales.count}, Total: £${sales.total}`,
});
});
});Data cleanup
Archive old records, clean up temporary data:
export default ArchiveOldOrders(async (ctx) => {
const cutoff = new Date();
cutoff.setFullYear(cutoff.getFullYear() - 2);
const oldOrders = await ctx.step("find old orders", async () => {
return await models.order.findMany({
where: {
createdAt: { lessThan: cutoff },
archived: false,
},
take: 1000,
});
});
for (const order of oldOrders) {
await ctx.step(`archive-${order.id}`, async () => {
await models.orderArchive.create({
originalId: order.id,
data: JSON.stringify(order),
archivedAt: new Date(),
});
await models.order.update({
where: { id: order.id },
data: { archived: true },
});
});
}
});Why steps matter
Wrapping work in ctx.step() gives you:
Durability — Completed steps don't re-run if the flow restarts. If you're processing 100 orders and fail on order 50, the first 49 won't be reprocessed.
Automatic retries — Each step retries independently on failure. A flaky API call won't fail your entire flow.
Visibility — The Console shows which steps completed, failed, or are pending. Debug failures at the step level.
Idempotency — Steps run at most once. Critical for operations like sending emails or charging payments.
Monitoring
Scheduled flow runs appear in the Console under Flows. Each run shows:
- When it started and completed
- Status (running, completed, failed)
- Step-by-step execution trace
- Any errors that occurred
Failed flows show which step failed. Click into the trace to see error details and stack traces.