Scheduling
Status: Preview
CRON scheduling requires a full Supabase instance and the Supabase pg_cron configured for workflow dispatch flag. It is disabled by default in self-hosted Community Edition installs. See Known Preview Surfaces.
ConnectedLenses workflows can be triggered on a CRON schedule. The mechanism is pg_cron driving rows in lenses.workflow_schedules, each carrying a five-field CRON expression, a timezone, an assignee (agent or team), and a four-policy bundle (approval / retry / failure / queue).
Architecture
Schedule row
| Column | Notes |
|---|---|
id | uuid PK |
workflow_id | FK to lenses.workflows |
cron_expr | Five-field CRON (validated by fn_upsert_workflow_schedule) |
timezone | IANA timezone — added in 20260428010000. Default 'UTC'. |
is_active | Pause/resume without deleting |
assignee_type | 'agent' | 'team' |
assignee_id | Polymorphic — agents.ai_lensers.id or agents.teams.id |
workflow_assignment_id | Optional explicit FK to agents.workflow_assignments |
approval_policy | jsonb default {"requiresApproval":true} |
retry_policy | jsonb default {"maxRetries":1} |
failure_policy | jsonb default {"mode":"isolate"} |
queue_policy | jsonb default {"mode":"parallel"} |
inputs_template | jsonb — caller-supplied default inputs |
global_model_id | Optional override applied to every node |
next_run_at | Computed by tick fn |
last_run_at, last_run_id | Last dispatched run |
last_dispatch_status | dispatched / skipped_overlap / validation_failed / dispatch_failed / paused |
last_error_at, last_error_message | Last failure trace |
last_completed_at, last_result | Terminal run snapshot |
TypeScript: WorkflowScheduleRecord.
Read RPC
public.fn_workflow_get_schedules(p_workflow_id uuid DEFAULT NULL)Returns every schedule the active workspace owns (joins lenses.workflows filtered by lensers.get_auth_lenser_id()). When p_workflow_id is supplied, scopes to that workflow. Source: supabase/migrations/20260428010000_ai_catalog_agent_control_room.sql:692.
Upsert RPC
public.fn_upsert_workflow_schedule(
p_workflow_id uuid,
p_schedule_id uuid DEFAULT NULL,
p_cron_expr text DEFAULT '* * * * *',
p_timezone text DEFAULT 'UTC',
p_global_model_id text DEFAULT NULL,
p_inputs_template jsonb DEFAULT '{}',
p_is_active boolean DEFAULT true,
p_assignee_type text DEFAULT 'agent',
p_assignee_id uuid DEFAULT NULL,
p_workflow_assignment_id uuid DEFAULT NULL,
p_approval_policy jsonb DEFAULT '{"requiresApproval":true}',
p_retry_policy jsonb DEFAULT '{"maxRetries":1}',
p_failure_policy jsonb DEFAULT '{"mode":"isolate"}',
p_queue_policy jsonb DEFAULT '{"mode":"parallel"}'
)Validations enforced server-side:
- Caller must own the workflow (
v_owner_id <> lensers.get_auth_lenser_id()raises42501). - CRON expression must split into exactly five fields (raises
22023). assignee_typemust be'agent'or'team'(raises22023).- Activating a schedule on a workflow with cycles is rejected (
22023 cycle_detected).
Source: supabase/migrations/20260428010000_ai_catalog_agent_control_room.sql:762.
Policy bundles
Each schedule (and each agents.workflow_assignments row) carries four JSONB policy slots. The defaults are conservative — every new schedule requires approval and limits retries to one.
approval_policy
| Field | Type | Notes |
|---|---|---|
requiresApproval | boolean | Top-level switch |
mode | 'every_node' | 'sensitive_actions' | 'on_block' | When approval triggers (matches autonomy levels) |
gates | string[] | Always-required gates (publish / spend / delete / external_message / schedule_change / ...) |
retry_policy
| Field | Type | Notes |
|---|---|---|
maxRetries | int | Per-node retry budget |
backoffMs | int | Exponential base; engine adds jitter |
retryOn | string[] | Subset of timeout / provider_error / rate_limit / contract_violated |
failure_policy
| Field | Type | Notes |
|---|---|---|
mode | 'isolate' | 'halt' | 'fallback' | What to do when a node fails after retries |
fallbackAssigneeId | uuid | When mode='fallback', reassign to this agent/team |
queue_policy
| Field | Type | Notes |
|---|---|---|
mode | 'parallel' | 'serial' | Whether the schedule may overlap its previous run |
maxConcurrency | int | Per-schedule concurrency cap |
priority | int | Worker queue priority |
Timezone behavior
The timezone column accepts any IANA timezone string. The dispatch function converts the CRON expression to UTC using pg_catalog.timezone() before computing next_run_at.
Key rule: pg_cron itself always fires on UTC clock ticks. The timezone field only affects how your CRON expression is interpreted, not when pg_cron wakes up.
Examples
| Timezone | CRON | Wall-clock meaning | UTC equivalent (winter) | UTC equivalent (summer) |
|---|---|---|---|---|
UTC | 0 8 * * * | 08:00 UTC | 08:00 | 08:00 |
Europe/Istanbul | 0 8 * * * | 08:00 Turkey time (UTC+3, no DST) | 05:00 | 05:00 |
America/New_York | 0 8 * * * | 08:00 Eastern time (DST-aware) | 13:00 (EST) | 12:00 (EDT) |
Europe/Istanbul has no daylight saving time — the UTC offset stays at +3 year-round. America/New_York observes DST, so the UTC equivalent shifts by one hour between winter (EST, UTC−5) and summer (EDT, UTC−4).
Recommendation: Use UTC for automated systems that must fire at a precise UTC clock time. Use a named IANA timezone (e.g., Europe/Istanbul) for schedules that should track a local business day.
Verify your timezone
-- Confirm pg_cron fires at the expected UTC time for your expression
SELECT timezone('Europe/Istanbul', now()) AS istanbul_now,
now() AT TIME ZONE 'UTC' AS utc_now;Self-hosted pg_cron requirements
Supabase Cloud: pg_cron is pre-installed. No setup required.
Self-hosted Supabase: pg_cron must be enabled explicitly.
Add to
postgresql.conf:shared_preload_libraries = 'pg_cron' cron.database_name = 'postgres'Restart Postgres, then run:
sqlCREATE EXTENSION IF NOT EXISTS pg_cron;Verify:
sqlSELECT * FROM pg_extension WHERE extname = 'pg_cron'; -- Must return one rowGrant the dispatch function permission to run as superuser or ensure
pg_cronis configured with the correctcron.database_name.
If pg_cron is missing, the dispatch-scheduled-workflows job will not be registered on migration and CRON scheduling will silently do nothing. Run lf schedule health to detect this — a healthy install shows worker: ok and pg_cron: registered.
Missed-run policy
The default behavior on a missed run (engine restart, downtime, paused window): skip. The next tick computes next_run_at forward and dispatches once; missed slots are not back-filled.
Owners can opt into other modes by adding to queue_policy:
queue_policy.onMissed | Behavior |
|---|---|
'skip' (default) | Drop missed slots; next tick is the next future occurrence. |
'run_once' | Dispatch one run on next tick to represent the missed window. |
'backfill' | Dispatch one run per missed slot (capped by queue_policy.maxBackfill). |
Runtime limits
Every dispatched run carries the policy bundle. The engine enforces:
| Limit | Source |
|---|---|
| Per-node max retries | retry_policy.maxRetries |
| Per-node timeout | Engine default + retry_policy.timeoutMs override |
| Per-run runtime | Engine config |
| Per-run cost cap | agents.policies.spending_limit_credits (AgentPolicyRecord) |
| Per-run token cap | Engine config + model_profiles.params.maxTokens |
| Per-run tool-call cap | tool_profile.allow_tools set + engine cap |
| Per-schedule concurrency | queue_policy.maxConcurrency |
| Per-day battles | agents.policies.max_daily_battles |
When any limit trips, the engine writes node.failed (or run.failed) with a cause payload; failure_policy decides next action.
CRON cannot bypass approvals
This is a non-negotiable:
- A schedule with
approval_policy.requiresApproval=truealways creates ateam_runrow withapproval_status='pending'. The engine does not start node execution until the human owner moves the row toapproved. - A schedule on an autonomous-with-gates assignment that touches a gate action (publish, spend, external_message, ...) likewise blocks on the gate even though the schedule itself is autonomous.
- Bypass attempts (a service-role caller setting
approval_status='not_required'for a sensitive run) MUST be flagged in the audit log.
Audit
Every dispatched run produces:
- A row in
lenses.workflow_runs. - A row in
agents.team_runs(when assignee is a team) or a directworkflow_runsclaim (when assignee is an agent). - Append-only events in
lenses.workflow_run_eventsfor the workflow run. - Append-only events in
agents.agent_run_eventsfor the team run (one per step transition). - A schedule-dispatch entry in
AgentAutomationFeedItem(kind='schedule_dispatch').
The dispatch status is also written to lenses.workflow_schedules.last_dispatch_status so the schedule list page can show the most recent outcome without a separate fetch.
CLI surface
CLI coverage is partial today. See cli-reference.md for the proposed lenserfight schedule subcommand tree.
Future work
The following are Proposed (not yet implemented):
schedule create / pause / resume / delete / list / inspectCLI commands — direct callers forfn_upsert_workflow_schedule,fn_workflow_get_schedules, and the to-be-added pause and delete RPCs.fn_pause_workflow_schedule(uuid)andfn_resume_workflow_schedule(uuid)— single-purpose RPCs so paused state cannot be confused withis_active=false from misuse.- Schedule-history view — a query view that returns the last N dispatched runs for a schedule with status, cost, and approval outcome.
- Audit event for bypass attempts —
agents.agent_run_eventswithevent_type='approval_bypass_attempt'whenever a schedule withrequiresApproval=trueis dispatched without a correspondingapproveddecision.