Skip to Content
Polyant is open source under AGPL-3.0 — star us on GitHub.
Admin PanelScheduled Tasks

Scheduled Tasks

On this page

Scheduled tasks are per-instance jobs that fire on a schedule (or on demand) and run a prompt through the supervisor as if a user had sent a message. Use them for recurring digests, polling integrations that don’t push webhooks, periodic clean-up prompts, or one-shot reminders.

No top-level sidebar entry. Scheduled tasks are not exposed at /scheduled-tasks or anywhere in the global sidebar. They live only inside an agent’s own page, as the Scheduled section under the Automation entry of its Behaviour group (?tab=scheduled). To see every scheduled task across the installation you have to open each agent in turn.

The UI is the Scheduled section behind the agent’s Automation entry, in the Behaviour group, which also opens Webhooks and Room as sibling sections - there is no wrapping “Triggers” section. Run history lives in Log, under Activity. Each row is one task with inline controls for enable/disable, edit, Run now, and delete.

What a task looks like

A task carries enough state to run autonomously and report back. The fields below mirror scheduled_tasks in packages/engine/src/scheduled-tasks/schema.ts.

FieldTypeNotes
namestringOperator-facing label.
descriptionstring (optional)Free-text reminder of what the task does.
promptstringThe user-message text sent to the supervisor when the task fires.
schedulediscriminated union (cron / interval / one-shot)See below.
enabledbooleanMaster switch. Disabled tasks never tick.
outboundChannelenum (optional)slack, telegram, whatsapp, or unset. Where the answer is sent.
outboundTargetstring (optional)Channel id or user id within outboundChannel.
keepHistorybooleanIf false, each run uses a fresh conversation id (no memory carry-over).
deleteAfterRunbooleanOne-shot tasks delete themselves after success.
maxRetriesint (default 3)Auto-disable after this many consecutive errors.

Schedule shape

type ScheduleConfig = | { type: "cron"; expression: string; timezone?: string } | { type: "interval"; everyMs: number; anchorAt?: string } | { type: "one-shot"; runAt: string };
  • Cron uses standard 5-field POSIX cron, optionally timezone-anchored.
  • Interval ticks every everyMs from anchorAt (or task creation time).
  • One-shot fires once at the given ISO timestamp. Combine with deleteAfterRun: true for fire-and-forget reminders.

Lifecycle

  1. The scheduler service ticks every 30 s (TICK_INTERVAL_MS = 30_000, packages/engine/src/scheduled-tasks/scheduler.service.ts:26).
  2. On each tick it claims up to MAX_CONCURRENT = 3 tasks whose nextRunAt is in the past, in priority order, and runs them in parallel.
  3. Each fire emits a cron event on the activity bus before the supervisor runs - useful as a heartbeat in the Activity feed.
  4. The supervisor processes the prompt exactly as if it were a user message. The conversation id is either reused (keepHistory: true) or freshly minted (keepHistory: false).
  5. The reply, if any, is delivered to outboundChannel/outboundTarget when configured. Otherwise it stays in the conversation for browsing.
  6. The run is recorded in scheduled_task_runs (run_id, started_at, completed_at, duration_ms, status, output, error, tool_calls, token_usage).
  7. On error, consecutive_errors increments. After maxRetries failures the task is auto-disabled and shows the error badge in the UI.

A run whose process never came back

A run used to be able to disappear without a trace. markRunning set the row’s status to running, nothing ever cleared it, and the query that picks up due tasks skips a row in that state - so a process killed mid-run by a deploy, an OOM or a crash left the task silent, with no error anywhere.

Two mechanisms close that, and they are deliberately not the same one:

  • On startup, rows left running whose last update is older than the orphaned-run grace (15 minutes by default) are assumed to belong to a process that is gone, and are recovered so the task runs again. Rows younger than that are left alone: during a rolling deploy the outgoing process may still be legitimately running them, and stealing a live run means executing the task twice.
  • On every tick, a reaper fails any run past its deadline - the task’s own max_run_ms, or the default run deadline (30 minutes) when it declares none. The default is deliberately generous, because reaping a run that is still alive is the worse of the two failures: the row is re-armed while the old execution keeps going.

Both windows are Installation policies a platform admin edits in the panel; the SCHEDULER_ORPHAN_GRACE_MS and SCHEDULER_DEFAULT_MAX_RUN_MS environment variables are no longer read.

One bad audit entry no longer kills the batch either - a run that fails to record stops itself, not its neighbours.

Watching the loop from outside

GET /health/scheduler is public and answers in numbers rather than a status word, because the failure mode is silence: nothing throws, every task stays enabled, and a plain health check keeps saying ok while nothing runs.

FieldWhat a bad value means
freeSlotsconcurrency slots not held by a run. Zero across consecutive scrapes means the loop is wedged - concurrency is process-wide, so runs that never finish stop every agent’s tasks
stuckRunningrows marked running past the default deadline. Non-zero for more than a tick or two is the signature this endpoint exists to expose

Counts only: no agent names, no task names, nothing an unauthenticated caller could use to enumerate tenants.

API

All endpoints are scoped to an instance - replace :slug with the instance slug.

MethodPathDescription
GET/api/instances/:slug/scheduled-tasksList tasks for the instance.
POST/api/instances/:slug/scheduled-tasksCreate a task.
GET/api/instances/:slug/scheduled-tasks/:idGet one task (full state).
PATCH/api/instances/:slug/scheduled-tasks/:idUpdate fields (name, prompt, schedule, enabled, …).
DELETE/api/instances/:slug/scheduled-tasks/:idRemove the task and cascade-delete its runs.
POST/api/instances/:slug/scheduled-tasks/:id/runTrigger an immediate execution (triggerType: manual).
GET/api/instances/:slug/scheduled-tasks/:id/runsPaginated run log for a single task (most recent first).
GET/api/instances/:slug/scheduled-tasks/runsCross-task global run log for the instance (no :id).

Worked example: daily digest at 09:00 Europe/Rome

  1. Open the agent → Behaviour → Automation → Scheduled → New task.
  2. Name: Morning digest.
  3. Schedule: cron, expression 0 9 * * *, timezone Europe/Rome.
  4. Prompt: “Search the last 24 hours of memories and summarise the three most important events. Be terse.”
  5. Outbound channel: slack, target: C012345678.
  6. Enable, save.

The next morning at 09:00 Rome time you’ll see one row in the Activity feed (cron), one tool call (searchMemory), one outbound (slack), and one entry in the Runs subtab.

Caveats

  • Tick granularity is 30 s. Don’t expect sub-minute accuracy.
  • Max concurrency is 3. A burst of due tasks queues; the rest wait for the next tick.
  • Engine restart resets nextRunAt for interval schedules. The scheduler recomputes the next fire from lastRunAt. Cron schedules are unaffected.
  • Auto-disable is per-task, not global. A flaky task won’t take down its neighbours.
  • A manual run is audited before it fires, not after - the run is fire-and-forget, so recording it afterwards would mean recording nothing when it does not come back.

See also: Activity for live visibility into firings, Activity Stream (concept) for the underlying bus.

Last updated on