Skip to content

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.

apiVersion: omnia.altairalabs.ai/v1alpha1
kind: ScheduledFunction

Short 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.

apiVersion: omnia.altairalabs.ai/v1alpha1
kind: ScheduledFunction
metadata:
name: hourly-digest
namespace: my-workspace
spec:
functionRef:
name: digest-fn
schedule:
cron: "@hourly"
runAs:
systemUser: {}

The function-mode AgentRuntime to fire, in the same namespace.

FieldTypeRequired
namestringYes
FieldTypeDefaultDescription
cronstring— (required)Five-field cron expression (0 6 * * *) or a descriptor: @hourly, @daily, @weekly, @monthly, @yearly, @every <duration>
timezonestringUTCIANA zone the expression is evaluated in
concurrencyPolicyenumForbidAllow 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.

FieldTypeRequiredDescription
cursorFromstringYesJSON Pointer (RFC 6901) into the fire’s output
cursorIntostringYesJSON Pointer into the next fire’s input
initialJSONNoCursor value used before the first successful fire
storeobjectYesWhere 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.

configMap is the store provider, and it must be set (CEL: has(self.configMap)).

FieldTypeDefaultDescription
configMap.namestring<schedule-name>-stateConfigMap holding the cursor
configMap.keystringcursorKey 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.

FieldTypeDescription
systemUserobjectFire 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.

FieldTypeDefaultDescription
suspendbooleanunsetStop firing without deleting the object. Unset is a third state — see below
startingDeadlineSecondsint32300 (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
timeoutSecondsint3260 (min 1)Abandon a fire that runs longer than this; the fire’s outcome is TimedOut
inputobject—The frozen parameter set, validated against the function’s inputSchema before any fire. The cursor is written into it at state.cursorInto
FieldDescription
nextFireTimeWhen the schedule fires next
lastScheduleTimeThe last cron tick the schedule handled, whether it fired or skipped it; nextFireTime is computed forward from here
lastFireTimeWhen the last fire was dispatched
lastSuccessTimeWhen a fire last succeeded
lastOutcomeSucceeded, Failed, TimedOut or Unknown
lastInvocationIDCorrelates with the session row for that fire
lastSkipTimeWhen a due tick was last skipped
lastSkipReasonWhy a due tick did not fire
consecutiveFailuresReset on success
virtualUserIDThe pseudonymous subject this schedule’s sessions are recorded under (sessions.virtual_user_id); filter on it to list the schedule’s past fires
lastManualTriggerThe omnia.altairalabs.ai/trigger-now annotation value already acted on
stateWhere the cursor lives — provider, ref and updatedAt. Never the value itself
inFlightMarker for a dispatch in progress — invocationID and since. A marker older than timeoutSeconds resolves to outcome Unknown on controller restart
conditionsSpecValid (is the spec and what it references valid) and Ready (did the last fire work)
observedGenerationLast generation the controller observed

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 is optional, so true, false and unset are all distinct:

ValueMeaningFires?
trueSomebody paused thisNo — lastSkipReason: Suspended
falseSomebody said this should runYes, always
unsetNobody has saidDepends 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.

ReasonMeaning
Suspendedspec.suspend is true
SuspendedByDefaultspec.suspend is unset and the operator runs with --hold-unreviewed-schedules
MissedDeadlineThe tick was later than startingDeadlineSeconds
ConcurrencyForbiddenA fire was still in flight under Forbid
DispatchCapReachedThe operator’s global in-flight dispatch cap was full
DispatchStartFailedThe 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.

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.

The operator registers these on its metrics endpoint. They are operational signals; status.lastOutcome is the record of whether a fire succeeded.

MetricTypeLabelsDescription
omnia_scheduled_function_fires_totalcounterschedule, namespace, outcomeSettled fires by outcome (Succeeded, Failed, TimedOut). A cursor advance failure counts as Failed
omnia_scheduled_function_fire_duration_secondshistogramschedule, namespaceSeconds from status.inFlight being set to the fire settling, for every outcome. Default Prometheus buckets
omnia_scheduled_function_seconds_since_last_successgaugeschedule, namespaceSeconds since status.lastSuccessTime, set once per reconcile. Absent until a fire has succeeded
omnia_scheduled_function_cursor_write_failures_totalcounterschedule, namespaceFires 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.

Terminal window
$ kubectl get sf
NAME READY FUNCTION SCHEDULE SUSPENDED LAST NEXT AGE
hourly-digest True digest-fn @hourly false Succeeded 2026-08-22T15:00:00Z 3d