ScheduledFunction CRD
The ScheduledFunction custom resource fires a function-mode AgentRuntime on a cron schedule, optionally carrying a cursor from one fire into the next.
There is no new deployable and no per-fire Kubernetes object. The operator
dispatches over HTTP with its own ServiceAccount token, and each fire’s history
is the ordinary sessions row the facade already writes.
API version
Section titled “API version”apiVersion: omnia.altairalabs.ai/v1alpha1kind: ScheduledFunctionShort name: sf (e.g. kubectl get sf).
Only function-mode AgentRuntimes can be scheduled — the target is invoked at
POST /functions/{name}, so a conversational agent has no schedulable surface.
Minimal example
Section titled “Minimal example”apiVersion: omnia.altairalabs.ai/v1alpha1kind: ScheduledFunctionmetadata: name: hourly-digest namespace: my-workspacespec: functionRef: name: digest-fn schedule: cron: "@hourly" runAs: systemUser: {}Spec fields
Section titled “Spec fields”functionRef
Section titled “functionRef”The function-mode AgentRuntime to fire, in the same namespace.
| Field | Type | Required |
|---|---|---|
name | string | Yes |
schedule
Section titled “schedule”| Field | Type | Default | Description |
|---|---|---|---|
cron | string | — (required) | Five-field cron expression (0 6 * * *) or a descriptor: @hourly, @daily, @weekly, @monthly, @yearly, @every <duration> |
timezone | string | UTC | IANA zone the expression is evaluated in |
concurrencyPolicy | enum | Forbid | Allow or Forbid |
The controller’s parse is the source of truth for what counts as a valid schedule. The CRD carries no pattern constraint: one that admitted every descriptor also admitted nonsense.
concurrencyPolicy: Replace does not exist. An in-flight HTTP invocation cannot
be cancelled; abandoning the response still leaves the runtime spending provider
tokens to completion.
Declares which slice of a fire’s output is carried into the next fire.
| Field | Type | Required | Description |
|---|---|---|---|
cursorFrom | string | Yes | JSON Pointer (RFC 6901) into the fire’s output |
cursorInto | string | Yes | JSON Pointer into the next fire’s input |
initial | JSON | No | Cursor value used before the first successful fire |
store | object | Yes | Where the cursor is kept |
If cursorFrom resolves to nothing on an otherwise successful fire, the stored
cursor carries forward unchanged — a function that returned no new cursor
has not asked to forget where it was.
initial accepts a scalar ("2026-01-01T00:00:00Z", 0, "page-2") as well
as an object. The useful cursor shapes are mostly scalars.
state.store
Section titled “state.store”configMap is the store provider, and it must be set (CEL: has(self.configMap)).
| Field | Type | Default | Description |
|---|---|---|---|
configMap.name | string | <schedule-name>-state | ConfigMap holding the cursor |
configMap.key | string | cursor | Key within its data |
A ConfigMap the controller creates from the default name is owned by the schedule and garbage-collected with it. One you name explicitly is adopted, written to, and left behind on delete, so state can outlive a recreate.
| Field | Type | Description |
|---|---|---|
systemUser | object | Fire as a stable per-schedule principal, system:schedule:<workspace>/<name> |
systemUser is the only arm, and it must be set (CEL: has(self.systemUser)).
Because the principal is stable per schedule, agentic memory continuity falls out for free: successive fires are the same end user, so a schedule can remember across runs without any additional field.
Other fields
Section titled “Other fields”| Field | Type | Default | Description |
|---|---|---|---|
suspend | boolean | unset | Stop firing without deleting the object. Unset is a third state — see below |
startingDeadlineSeconds | int32 | 300 (min 0) | Skip a fire that is later than this past its slot — after operator downtime, schedules resume rather than storm through every tick they slept past |
timeoutSeconds | int32 | 60 (min 1) | Abandon a fire that runs longer than this; the fire’s outcome is TimedOut |
input | object | — | The frozen parameter set, validated against the function’s inputSchema before any fire. The cursor is written into it at state.cursorInto |
Status fields
Section titled “Status fields”| Field | Description |
|---|---|
nextFireTime | When the schedule fires next |
lastScheduleTime | The last cron tick the schedule handled, whether it fired or skipped it; nextFireTime is computed forward from here |
lastFireTime | When the last fire was dispatched |
lastSuccessTime | When a fire last succeeded |
lastOutcome | Succeeded, Failed, TimedOut or Unknown |
lastInvocationID | Correlates with the session row for that fire |
lastSkipTime | When a due tick was last skipped |
lastSkipReason | Why a due tick did not fire |
consecutiveFailures | Reset on success |
virtualUserID | The pseudonymous subject this schedule’s sessions are recorded under (sessions.virtual_user_id); filter on it to list the schedule’s past fires |
lastManualTrigger | The omnia.altairalabs.ai/trigger-now annotation value already acted on |
state | Where the cursor lives — provider, ref and updatedAt. Never the value itself |
inFlight | Marker for a dispatch in progress — invocationID and since. A marker older than timeoutSeconds resolves to outcome Unknown on controller restart |
conditions | SpecValid (is the spec and what it references valid) and Ready (did the last fire work) |
observedGeneration | Last generation the controller observed |
Manual fire
Section titled “Manual fire”Setting the annotation omnia.altairalabs.ai/trigger-now to a new value fires
the schedule once. It is a real fire: it splices and advances the cursor,
records the same session row, and obeys concurrencyPolicy. It fires once per
distinct value, however many reconciles observe it. A suspended schedule
ignores it, and records it in lastManualTrigger as handled, so unsuspending
the schedule does not replay it. To fire after unsuspending, set a new value.
suspend has three states, not two
Section titled “suspend has three states, not two”suspend is optional, so true, false and unset are all distinct:
| Value | Meaning | Fires? |
|---|---|---|
true | Somebody paused this | No — lastSkipReason: Suspended |
false | Somebody said this should run | Yes, always |
| unset | Nobody has said | Depends on the operator (below) |
By default, an unset suspend fires.
An operator started with --hold-unreviewed-schedules (chart value
scheduledFunctions.holdUnreviewed: true) instead holds every schedule whose
suspend is unset, reporting lastSkipReason: SuspendedByDefault. An explicit
suspend: false is never overridden, so a schedule that states its intent runs
on any operator.
See Schedule a function
for when to enable --hold-unreviewed-schedules.
Skip reasons
Section titled “Skip reasons”| Reason | Meaning |
|---|---|
Suspended | spec.suspend is true |
SuspendedByDefault | spec.suspend is unset and the operator runs with --hold-unreviewed-schedules |
MissedDeadline | The tick was later than startingDeadlineSeconds |
ConcurrencyForbidden | A fire was still in flight under Forbid |
DispatchCapReached | The operator’s global in-flight dispatch cap was full |
DispatchStartFailed | The dispatch could not be started |
DispatchCapReached is a global operator cap, not a per-workspace quota. It
protects the operator from one workspace’s fan-out saturating dispatch and
slowing every other reconciler. A fire that cannot acquire a slot is skipped,
never queued.
The cap is the operator flag --scheduled-function-max-concurrent-dispatches,
an integer. The default is 10 in-flight fires across every schedule in the
cluster; a value of 0 or less also selects 10. The chart has no value for
it; set it through operator.extraArgs.
A manual fire refused with DispatchCapReached stays pending: the controller
restores status.lastManualTrigger to its previous value, so the next reconcile
retries the same trigger-now request. A manual fire refused with
ConcurrencyForbidden is consumed.
status holds scalars only. The cursor’s value lives in the state store,
never on the object — status.state is a pointer to it, so a cursor carrying a
customer identifier does not end up readable in kubectl get sf -o yaml.
Cursor advance semantics
Section titled “Cursor advance semantics”The cursor advances only on HTTP 200 with output that is valid JSON and
satisfies the function’s outputSchema. If the advance itself fails, the fire
is recorded as Failed. There are no retries within a period: the next tick is
the retry.
Metrics
Section titled “Metrics”The operator registers these on its metrics endpoint. They are operational
signals; status.lastOutcome is the record of whether a fire succeeded.
| Metric | Type | Labels | Description |
|---|---|---|---|
omnia_scheduled_function_fires_total | counter | schedule, namespace, outcome | Settled fires by outcome (Succeeded, Failed, TimedOut). A cursor advance failure counts as Failed |
omnia_scheduled_function_fire_duration_seconds | histogram | schedule, namespace | Seconds from status.inFlight being set to the fire settling, for every outcome. Default Prometheus buckets |
omnia_scheduled_function_seconds_since_last_success | gauge | schedule, namespace | Seconds since status.lastSuccessTime, set once per reconcile. Absent until a fire has succeeded |
omnia_scheduled_function_cursor_write_failures_total | counter | schedule, namespace | Fires downgraded to Failed because the cursor could not be saved to the store |
schedule is the ScheduledFunction name. An Unknown outcome, resolved when the
operator restarts, is not counted in fires_total.
Printed columns
Section titled “Printed columns”$ kubectl get sfNAME READY FUNCTION SCHEDULE SUSPENDED LAST NEXT AGEhourly-digest True digest-fn @hourly false Succeeded 2026-08-22T15:00:00Z 3dRelated
Section titled “Related”- Schedule a function — task guide
- AgentRuntime CRD —
mode: function