# Scheduled functions

> Run functions on a cron schedule with defineSchedule: five-field cron expressions, IANA timezones, build-time validation, and at-least-once delivery.

Scheduled functions run on a cron schedule instead of an HTTP request. Declare one with `defineSchedule` in `functions/_scheduled/`, where the underscore keeps the file out of routing, and deploy: the platform registers the schedule and fires your handler at the times the cron describes. Schedules run in production only: `helix dev` warns about `_scheduled/` files and does not run them locally.

## Defining a schedule

```typescript
// functions/_scheduled/daily-report.ts
import { defineSchedule } from '@trayai/helix-sdk';

export default defineSchedule({
  cron: '0 9 * * MON-FRI',   // 9am on weekdays
  timezone: 'Europe/London',
  concurrent: false,         // whether runs of this schedule may overlap
  handler: async (ctx) => {
    const kv = ctx.kv();
    const signups = (await kv.getItem<number>('signups:today')) ?? 0;

    ctx.log.info('Daily signup report', { signups });

    await ctx.http.post('https://hooks.slack.com/services/...', {
      json: { text: `Daily report: ${signups} new signups` },
    });

    await kv.setItem('signups:today', 0);
  },
});
```

The handler receives the platform context: `ctx.kv()`, `ctx.http`, `ctx.log`, and the rest. What it doesn't have is request data, so `ctx.input`, `ctx.method`, and `ctx.headers` are not available. The return value is discarded.

## Configuration

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `cron` | `string` | Yes | Five-field standard cron expression: minute, hour, day, month, weekday |
| `timezone` | `string` | No | Named IANA timezone, for example `'Europe/London'` or `'America/New_York'`. Defaults to UTC. |
| `concurrent` | `boolean` | No | Whether a fire may overlap the previous run of the same schedule. Defaults to `false`. |
| `handler` | `function` | Yes | The function to execute. Receives `ctx` with all platform services. The return value is discarded. |

### Validation happens at build time

Schedules are validated during the deploy build, and validation is fail-closed: one invalid schedule file fails the whole build, with every problem listed.

- **Five-field standard cron only.** Month and weekday names are accepted (`JAN`–`DEC`, `SUN`–`SAT`, case-insensitive), and `?` is treated as `*` in both day fields. Six-field expressions are rejected.
- **Day-of-month and day-of-week cannot both be constrained.** Standard cron's either-day union is not supported, so put `*` (or `?`) in one of the two day fields, or split the schedule into two files.
- **The timezone must be a named IANA zone** starting with a letter. Offset forms like `'+05:00'` are rejected.
- **At most 20 schedule files per project.**

### Overlapping runs

`concurrent` declares whether a fire may overlap the previous run of the same schedule. It defaults to `false`. The setting is per schedule, so it never affects the other schedules in a project, and the dashboard marks the jobs that set it to `true`.

:::roadmap{title="Overlap suppression is not enforced yet"}
The option is accepted at deploy time and the dashboard shows it, but a fire can still start while the previous run of that schedule is going, whichever way you set it. Declare the behavior you want, and until suppression ships, write every handler to tolerate running alongside itself.
:::

Delivery is at-least-once either way, so a handler whose work must not happen twice needs to be idempotent regardless.

:::note{title="The file is the source of truth"}
A schedule exists whenever its file exists in `functions/_scheduled/`: deploying a new file creates it, deploying without the file deletes it. There is no `enabled` config field. Pausing is a dashboard action rather than a config value, and paused state survives redeploys, so a deploy never silently re-enables a schedule someone paused.
:::

## The trigger payload

Inside the handler, `ctx.trigger` describes why this execution happened:

| Field | Type | Meaning |
|-------|------|---------|
| `type` | `'schedule'` | Always `'schedule'` for scheduled functions |
| `scheduledAt` | `Date` | When the cron said it should fire |
| `firedAt` | `Date` | When it actually started, stamped at invoke time, so dispatch latency, cold starts, and retries all widen the gap |
| `isManual` | `boolean` | Reserved for manual triggering, which is planned. Always `false` today. |

The gap between `scheduledAt` and `firedAt` reflects dispatch and cold-start latency. Log it if you care about precision. The execution ID lives on `ctx.executionId`, as in every function.

Nothing in the payload marks an execution as a retry: a handler cannot tell a retried run from a first run, so write handlers to be idempotent instead of gating side effects on how the run started.

## Retries and failures

Delivery is at-least-once. A run that throws fails the invocation and is retried once, starting five minutes after the failed run started; the delay is not configurable per schedule. If the retry also fails, the tick is dropped and never replayed, because a stale cron tick must not fire at a time nothing asked for. Both attempts appear in the project's Logs tab as errored runs, each with its own trace.

A retry means the same tick can run twice, and overlapping runs mean two ticks can run at once. Design for both: make the work idempotent, and use the [key-value store](/documentation/guides/key-value-store/) when a handler needs to remember what it already processed.

## Limits

These limits are enforced for scheduled functions today.

| Limit | Value |
|---|---|
| Schedule files per project | 20 maximum, enforced at build time |
| Fastest cadence | Once per minute |
| Run duration | 3 minutes maximum per run, not configurable |
| Retries | One retry, starting five minutes after the failed run started, then the tick is dropped |
| Overlap | Runs can still overlap. `concurrent` is accepted at deploy time but not enforced yet |

Five-field cron has minute resolution, so a schedule cannot fire more than once per minute. A run still going at 3 minutes fails and follows the same retry path as a thrown error, so break work that could run long into smaller ticks: store a cursor in the [key-value store](/documentation/guides/key-value-store/) and let each run pick up where the last one stopped.

Platform-wide limits, including everything the handler itself is subject to, are collected on the [limits reference](/documentation/reference/limits/).

## How production scheduling works

`helix deploy` uploads your source and the platform does the schedule work; the CLI never parses or registers schedules itself.

1. The build evaluates each file in `functions/_scheduled/` and extracts its cron, timezone, and overlap setting. This is where validation runs.
2. When the build completes, the platform reconciles the extracted set of schedules: new and changed schedules are registered in a disabled state, the new code is published, then every schedule is activated and schedules whose files are gone are deleted. Registering them disabled first means a failed deploy can't leave a schedule firing against unpublished code.
3. When a cron fires, the platform invokes your project's deployed functions and dispatches to the matching handler.

The schedule set is declarative, so the live state always matches the deployed code, and a schedule always runs the latest deployment's code without being re-registered.

## Local development

`helix dev` does not run schedules: it notices `functions/_scheduled/` at startup and warns that the files won't fire locally. A local scheduler and a manual-run command (`helix trigger`) are planned. Until they land, keep the schedule's logic in a shared module that an HTTP function also calls, and exercise it through the HTTP route while developing.

## Viewing and managing schedules

The [dashboard](/documentation/governance/dashboard/) is where you see and control schedules after a deploy. There is no `helix schedules` command; the CLI's part ends at `helix deploy`.

### The project's Scheduled Jobs tab

Open a project and pick **Scheduled Jobs** to see every schedule its live deployment declares:

| Column | Contents |
|---|---|
| Job | The schedule's file name, the description your code carries for it, and a marker when the job allows overlapping runs |
| Schedule | The cadence in words, such as "At 09:00, Monday through Friday". Hover for the cron expression. A job that fires every 15 minutes or more often is flagged as high frequency |
| Timezone | The schedule's timezone |
| Next run | A countdown and the local date and time of the next fire. Approximate, since it is derived from the cron expression, and empty for a paused job |
| Status | Active or Paused |

Search by job name or description, filter by All, Active, or Paused, and read the active and paused counts beside the filter.

### Pausing and resuming

**Pause** on a row stops a job firing and leaves it in place; **Resume** starts it again. Pausing applies to future fires and does not stop a run already going, and the paused state survives redeploys. The control appears only for people who can change the project, so a viewer sees the list without it.

Pause is the control to reach for when a job is causing trouble: removing the file also stops it, but that needs a deploy.

### Following what a job did

**View logs** in a row's actions menu opens the project's Logs tab filtered to that job's runs. Each run is one entry with the `SCHEDULE` trigger type, and opening an entry gives the same execution trace an HTTP request gets. A retried tick puts each attempt in the log as its own entry.

### The organization-wide view

Org admins get every schedule in the organization on one page: **Scheduled Jobs** in the [Admin console](/documentation/governance/admin-console/). It carries the same job, cadence, timezone, and status columns, plus the project and workspace each job belongs to. Search there covers project names as well as job names and descriptions, so you can pull up every schedule in one project without opening it. Pause and resume stay on the project's own tab, which the rows link to.

---

Canonical: https://helix.tray.ai/documentation/guides/scheduled-functions/
Any link on this page is available as markdown by appending .md to its URL.
Full corpus: https://helix.tray.ai/documentation/llms-full.txt