Scheduling

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 minutes
  • every N hours
  • every N minutes from T to T
  • every N hours from T to T
  • every 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-readableCron
every 10 minutes*/10 * * * *
every 30 minutes from 10am to 2pm*/30 10-14 * * *
every 2 hours0 */2 * * *
every monday at 9am0 9 * * 1
every weekday at 8am0 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 @schedule attribute per flow definition
  • A schedule that doesn't divide evenly into the time-unit (e.g. every 5 hours is invalid because 24 doesn't divide evenly by 5)
  • Invalid cron syntax (e.g. 0 9am * * * results in the error invalid 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.