# Schedule functions

Fire a [function-mode AgentRuntime](/how-to/agents/define-functions/) on a cron
schedule with fixed input, for recurring work that has no caller: a nightly
digest, an hourly sync, a periodic sweep of a queue.

Every spec field, status field, skip reason, condition and metric is in the
[ScheduledFunction reference](/reference/core/scheduledfunction/).

## Prerequisites

- A `mode: function` AgentRuntime in the workspace namespace, with
  `spec.facades[].managementPlane` enabled. The operator dispatches to the
  function's management-plane endpoint, so a schedule on a function without one
  reports `SpecValid=False` with reason `ManagementEndpointMissing`.

## Create the schedule

1. Apply a `ScheduledFunction` in the same namespace as the function:

   ```yaml
   apiVersion: omnia.altairalabs.ai/v1alpha1
   kind: ScheduledFunction
   metadata:
     name: daily-digest
     namespace: ws-acme
   spec:
     functionRef:
       name: daily-digest
     schedule:
       cron: "0 6 * * *"
       timezone: "Europe/London"
       concurrencyPolicy: Forbid
     input:
       region: emea
     runAs:
       systemUser: {}
   ```

   `input` is validated against the function's `inputSchema` before any fire.
   Each fire is recorded as an ordinary `sessions` row tagged `function`.

2. Set `suspend: false` if the operator may run with
   `--hold-unreviewed-schedules`. See
   [Holding schedules nobody has reviewed](#holding-schedules-nobody-has-reviewed).

## Carry state between runs

Skip this section if each run is independent. Otherwise declare a cursor: a
slice of one fire's output that Omnia splices into the next fire's input.

1. Add `spec.state`:

   ```yaml
   spec:
     input:
       region: emea
     state:
       cursorFrom: "/cursor"     # JSON Pointer into the fire's output
       cursorInto: "/cursor"     # JSON Pointer into the next fire's input
       initial:                  # used before the first successful fire
         since: "2026-01-01T00:00:00Z"
       store:
         configMap: {}           # defaults to a ConfigMap named <schedule>-state
   ```

2. Make the function read `cursor` from its input and return a new `cursor` in
   its output. With the example above it receives
   `{"region":"emea","cursor":{"since":"..."}}`.

Keep the cursor to a resumption pointer: a watermark timestamp, a last-processed
id or a page token. If the store is lost, the next fire reprocesses a window,
so never keep the only copy of a business fact in it. A cursor over 64 KiB fails
the fire.

The cursor advances only when the fire returns HTTP 200 with output that
validates against the function's `outputSchema`. After any other outcome the
stored cursor is unchanged and the next fire retries the same window. A fire
whose cursor could not be saved is recorded as `Failed`.

Use `Forbid` for any schedule that carries a cursor. Under `Allow`, overlapping
fires can move the cursor backwards.

If the function has [memory](/explanation/agents/memory/) enabled, each schedule
runs as the stable principal `system:schedule:<workspace>/<name>`, so what one
fire writes the next can recall.

## Make the function repeatable within a fire slot

In a DST-observing timezone, a schedule whose slot falls in the repeated hour
fires twice on fall-back day, and one whose slot falls in the skipped hour does
not fire on spring-forward day. A watermark-driven function absorbs the second
fire as an empty window. A function with non-idempotent side effects, such as
sending email or writing to a CRM, must be made idempotent within a fire slot,
or the schedule must use `timezone: UTC`.

## Holding schedules nobody has reviewed

An unset `suspend` fires by default. When the operator runs with
`--hold-unreviewed-schedules`, set from the chart value
`scheduledFunctions.holdUnreviewed: true`, schedules with `suspend` unset are
held with the skip reason `SuspendedByDefault`. Enable it where applications
are installed and tried out, so an install does not start recurring spend
against a paid provider unnoticed. State that a schedule should run:

```yaml
spec:
  suspend: false
```

## Run a schedule now

Set the trigger annotation to any new value. The dashboard's **Run now** does
the same:

```bash
kubectl annotate scheduledfunction daily-digest -n ws-acme \
  omnia.altairalabs.ai/trigger-now="$(date -u +%FT%TZ)" --overwrite
```

A manual fire is a real fire: it reads and advances the cursor, writes the same
session row and obeys `concurrencyPolicy`. It runs once per annotation value. A
suspended schedule consumes the value without firing, so unsuspending does not
replay it. If the operator's dispatch cap is full (`DispatchCapReached`), the
request stays pending and is retried on the next reconcile; see the
[dispatch cap](/reference/core/scheduledfunction/#skip-reasons).

To invoke the function without touching stored state, use the function's test
panel in the dashboard.

## Check a schedule

```bash
kubectl get scheduledfunction -n ws-acme
```

The columns include `READY`, `LAST` (the last fire's outcome) and `NEXT`. For
detail, read the status:

```bash
kubectl get scheduledfunction daily-digest -n ws-acme -o yaml
```

- `status.lastOutcome` and `status.consecutiveFailures` say how fires are going.
- `status.lastSkipReason` says why the last due tick did not fire.
- `status.state` points at where the cursor lives. It never holds the cursor
  value; read the ConfigMap for that.

The [reference](/reference/core/scheduledfunction/#status-fields) describes the
status fields, including the `SpecValid` and `Ready` conditions and the
timestamps. The `omnia_scheduled_function_*` metrics are listed under
[Metrics](/reference/core/scheduledfunction/#metrics).

## Troubleshooting

### The schedule never fires

Read `status.conditions` first.

- `SpecValid=False` names the spec problem: a missing `functionRef`, `input`
  that fails the function's `inputSchema`, a malformed cursor pointer, an
  `outputSchema` that will not compile, or `ManagementEndpointMissing`. The last
  one means the function's facade has `managementPlane` disabled.
- `Ready=False` with `DispatchStartFailed` means the tick was due but the
  dispatch could not start, for example because the cursor ConfigMap is
  unreadable. The cause is in the condition message and the skip event.

If both conditions are `True`, check `status.nextFireTime` and
`status.lastSkipReason`. The reasons are listed in the
[reference](/reference/core/scheduledfunction/#skip-reasons).

### `Ready=False` with `CronInvalid`

The controller parses `spec.schedule.cron`. It accepts standard five-field
expressions and the descriptors `@hourly`, `@daily`, `@weekly`, `@monthly`,
`@yearly` and `@every <duration>`.

### `lastOutcome: Unknown`

The operator restarted while the fire was in
flight, so its result was never observed. The cursor did not advance, so the
next fire retries the same window. `Unknown` does not increment
`consecutiveFailures`.

## Related pages

- [ScheduledFunction reference](/reference/core/scheduledfunction/)
- [Define functions](/how-to/agents/define-functions/)
- [Agentic memory](/explanation/agents/memory/)