Skip to content

Scheduled Tasks API

A scheduled task runs a stored prompt on a timer — daily, weekly, or on an interval — with no agent building and no workflow builder. Results are delivered into a normal chat conversation owned by the task's creator (marked unread until opened) and, optionally, by email. Tasks are user-owned (created_by); every read and mutation is fenced to the creator within their tenant.

Managing scheduled tasks requires the SCHEDULED_TASKS_AUTHOR permission; for the built-in roles that is member, KI-Manager, tenant admin, and admin (the read-only viewer and demouser roles are rejected 403), and a tenant custom role granting SCHEDULED_TASKS_AUTHOR is admitted regardless of its base role. Tasks remain fenced to the creator within their tenant. The base URL is https://ai-api-admin.myra.eu/admin/v1.

Internally a task reuses the platform's single scheduler (the same per-minute tick that drives agent schedules and workflow time-triggers) — the execution runs through the standard inference pipeline as the owner, so plan model clamps, fair-use budgets, the over-cap soft-degrade, and PII protection all apply to unattended runs exactly as they do to interactive chat.


Endpoints

Method Path Purpose
GET /scheduled-tasks List the caller's own tasks.
POST /scheduled-tasks Create a task.
PATCH /scheduled-tasks/{id} Edit / pause / resume a task (owner only).
DELETE /scheduled-tasks/{id} Delete a task (owner only).

A task id that does not exist, belongs to another user, or belongs to another tenant answers 404 (never 403, so ids cannot be probed).

All routes require the tenant's Scheduled tasks feature to be enabled: when scheduled_tasks_enabled is off, the endpoint returns 403 { "error": "feature_disabled", "feature": "scheduled_tasks" } (a transient failure reading that flag returns 503 { "error": "feature check unavailable" }). On create, a gateway_id that resolves to another tenant's gateway is refused with 403 { "error": "forbidden" } — the 404-not-403 rule above is for task ids, not the create-path gateway binding.

Request body

Field Shape Notes
gateway_id string Create only. Must be a gateway the caller can access (tenant-fenced).
name string Required on create. 1–255 bytes.
prompt string Required on create. 1–60000 bytes. The text sent to the model on every run.
model string Required on create. 1–128 bytes. For self-serve plans the model must be inside the plan's entitlement (rejected 400 otherwise — fail closed); for enterprise plans the runtime pipeline allowlist is the boundary. The model must also be dispatch-able on the task's gateway: if the gateway enforces EU data-residency or a provider allowlist and the pinned model would resolve to a non-EU / non-allowed provider, the save is rejected 400 (data_residency_blocked / provider_not_allowed) instead of being accepted and then failing every scheduled run — the same save-time offer gate the project default and agent model pins use. Validated only when the model field is present, so a task carrying an already-blocked model from before this gate stays editable for its other fields. A model that is later deprecated makes runs fail with a visible error; the task auto-pauses after 5 consecutive failed occurrences.
schedule_kind "interval" \| "daily" \| "weekly" Required on create. An interval task becomes due immediately — its first run starts within about a minute — and the cadence applies from then on; the same holds when an interval task's cadence is edited or it is resumed. A daily/weekly task instead first runs at the next occurrence of the chosen time (daily_at, UTC): "daily at 10:00" created at 08:36 first runs at 10:00, never an immediate extra run on save. Editing only metadata (renaming, or an already-enabled task) keeps the armed run time; it is recomputed only when the cadence changes or a paused task is resumed.
interval_sec integer interval kind: 60 … 31622400 (366 days). Accepts an integer value; a value that is not an integer in range is rejected 400. Sending it with a daily/weekly kind is rejected 400 (see Cadence fields belong to one kind below).
daily_at "HH:MM" daily/weekly kinds. UTC. (The UI converts local time; a stored instant is UTC-anchored, so across a daylight-saving change the local run time shifts by one hour — a documented v1 limitation.) Sending it with an interval kind is rejected 400.
daily_dow integer 0..6 weekly kind only (0 = Sunday … 6 = Saturday, UTC). Required for weekly; rejected outside 0..6. Sending it with an interval/daily kind is rejected 400.
email_to string Optional. A valid email address (max 254 bytes) adds email delivery on top of the conversation; empty string on PATCH removes it.
enabled boolean PATCH only. false pauses (the task keeps its config and its cap slot is freed); true resumes — the resume re-checks the cap.

Every field is validated fail-closed; a malformed value (wrong type, out of bounds, bad HH:MM, weekday out of range, oversized prompt) is rejected with 400 and a message naming the field. Unknown body fields are ignored. The integer cadence fields (interval_sec, daily_dow) are stored as canonical integers, so a syntactically-integer value never reaches the database in a form that would surface as a 500; anything the validator does not accept as an in-range integer is a 400.

Cadence fields belong to exactly one kind

A task's cadence is described by the field(s) its schedule_kind uses — interval uses interval_sec, daily uses daily_at, weekly uses daily_at and daily_dow — and nothing else:

  • A cadence field the kind does not use is rejected 400, on create and on PATCH, with a message naming the field (for example interval_sec together with schedule_kind: "daily"). It is never accepted and then quietly dropped.
  • Fields the new kind does not use are cleared when the kind changes. After a switch from interval to daily, the task's interval_sec is cleared and the field is simply absent from the response body — a task never reports a cadence it does not run on. Re-saving an older task's cadence clears any leftover field the same way.
  • A PATCH may change the kind alone when the stored task already carries what the new kind needs. {"schedule_kind": "daily"} on a weekly task succeeds and keeps the stored daily_at, because weekly used that same field. It is rejected 400 when the new kind needs a field the task's previous kind never used — switching a daily task to interval requires an explicit interval_sec, and is rejected rather than silently reusing a leftover value the request never named.
  • Sending null for a cadence field the kind uses means "leave it as it is": the stored value is kept. A PATCH whose only field is such a null changes nothing and is rejected 400 no fields to update.

Changing the kind re-arms the next run time (an interval task becomes due immediately, a daily/weekly task at the next occurrence of its time). Clearing a leftover field on its own does not — a save that does not change the cadence the task actually runs on keeps the armed run time.

Response shape. The response is wrapped: the list returns { "tasks": [ … ] }, while create (201) and patch (200) return { "task": { … } }. The fields below belong to each inner task object: id, gateway_id, name, prompt, model, schedule_kind, interval_sec, daily_at, daily_dow, enabled, email_to, last_status, last_error, last_run_at, next_run_at, result_conversation_id, created_at.

Active-task cap

Each user has a cap on active (non-paused) tasks, resolved from the tenant plan: the paid Custom plan 10, the free trial 0 (DB-tunable per plan-config row); non-self-serve (enterprise/manual) tenants get a deployment-wide default cap (10, set by your operator). Creating — or re-enabling a paused task — beyond the cap answers:

HTTP 403
{ "error": "scheduled_task_limit_reached", "cap": 10 }

Pausing or deleting a task frees its slot immediately.

Runs, retries, and failure handling

A due task is claimed by the scheduler tick (at-most-once per cadence slot) and executed through the gateway as the owner:

  • Transient provider failures (network error, HTTP 5xx, 429) are retried across ticks — up to 3 attempts per occurrence with a short backoff. The retry never postpones the task's real cadence slot.
  • Permanent failures (any other 4xx — e.g. a deprecated model or a hard budget block) are not retried; the occurrence is recorded as failed (last_status: "failed", last_error set) and the task waits for its next cadence slot.
  • After 5 consecutive failed occurrences the task auto-pauses (last_status: "paused", enabled: 0) so an unattended broken task cannot burn budget nightly. Resume it after fixing the cause.
  • Over the fair-use cap, self-serve runs inherit the plan's soft-degrade: the run executes on the tier's efficient model instead of hard-failing (premium models stay locked until the allowance resets or the plan is upgraded).

Delivery is at-least-once: the conversation write is the commit point, and a rare crash between delivery and bookkeeping may re-run an occurrence on the next tick (a duplicate message in the owner's conversation, never a duplicate charge beyond the run itself).

Result delivery

Each run appends the prompt and the answer as a normal user/assistant pair to the task's result conversation (created on first delivery, reused afterwards) and marks it unread (unread_at); opening the conversation clears the marker. If the owner deletes the result conversation, the next run creates a fresh one. The optional email delivery reuses the platform mailer with the stored recipient only. When the run carried masked PII, email is suppressed fail-closed unless the tenant has opted in (pii_internal_delivery_enabled) and the recipient is a tenant-internal address — see Internal delivery of masked-PII output below. The owner's own conversation is not an external egress and always receives the result.

Internal delivery of masked-PII output

When a scheduled agent, prompt-task, or workflow run masks PII during execution, the gateway refuses all external delivery of that run's (de-tokenized) output (Mattermost, webhook, and any non-same-tenant email recipient) — the owner is instead sent a content-free "delivery blocked" notice. A narrow, email-only relaxation delivers the unmasked output to a same-tenant recipient, governed by the tenant flag pii_internal_delivery_enabled (tenant-admin settable in Workspace settings, audited — see the tenant PATCH field). Since AGF-3098 / migration 0358 this flag defaults to 1 (ON) (it was 0/OFF before); a tenant admin can set it back to 0 to block every masked run's output:

  • Allowed: the run's unmasked output is emailed only when (1) the tenant flag is 1 and (2) the recipient resolves to an active, registered user of the same tenant. The check runs server-side in the deliver-email handler with the trusted tenant context and the recipient re-read from the stored schedule/owner — never from the scheduler's request.
  • Still blocked (fail-closed), no unmasked output ever egresses: the tenant has not opted in; the recipient is not an active same-tenant user (external address, a disabled user, a user of another tenant, or a non-user shared inbox); Mattermost delivery (unlike email it has no tenant-internal recipient — it cannot deliver the unmasked body to the owner); webhook delivery (an arbitrary URL is not a tenant recipient); the run's PII signal is absent/garbage; or any lookup error/uncertainty.

In every blocked case the owner still receives the content-free "delivery blocked" notice, so the outcome is never silent. The relaxation is a deliberate, per-tenant choice (now default-ON, AGF-3098); a tenant that sets the flag back to 0 returns to blocking every masked run's output. Note the boundary this does NOT move: external, cross-tenant, Mattermost, and webhook delivery of masked-PII output stay fail-closed regardless of this flag.

The two internal run endpoints (/v1/{tenant}/{gateway}/agents/scheduled-task/{id}/invoke and …/deliver-conversation) accept only the gateway service token (a user-bound token is refused with 404), and every execution parameter — prompt, model, owner, recipient, conversation — is read from the stored task row, never from the request body, so a leaked token cannot retarget a task. The invoke additionally accepts the X-AIG-Unattended: 1 header (the scheduler always sends it) — the same extended tool-loop budget contract as agent invokes; see Agents API — Unattended runs.

Lifecycle

Tenant deletion (GDPR purge) removes tasks via the gateway cascade; per-user erasure (Art. 17) deletes the user's tasks with their other self-created schedules. Deactivating or restricting (Art. 18) the owner stops unattended runs immediately — the run endpoints re-check the owner on every execution.