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 onPATCH, with a message naming the field (for exampleinterval_sectogether withschedule_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
intervaltodaily, the task'sinterval_secis 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
PATCHmay 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 storeddaily_at, becauseweeklyused that same field. It is rejected400when the new kind needs a field the task's previous kind never used — switching a daily task tointervalrequires an explicitinterval_sec, and is rejected rather than silently reusing a leftover value the request never named. - Sending
nullfor a cadence field the kind uses means "leave it as it is": the stored value is kept. APATCHwhose only field is such anullchanges nothing and is rejected400 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:
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_errorset) 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
1and (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.