Scheduled Workflows
This content is not available in your language yet.
A Scheduled Workflow runs one of your agents’ published workflows on a timer: every weekday at 9am, every 15 minutes, the first of every month. No inbound message is involved. At each scheduled time, the platform starts a headless run of the agent’s workflow, the same way runWorkflowTask does. This page is the field-by-field reference for the seven schedule operations. For the node/edge model the scheduled run executes, see Workflows Overview.
Added 2026-09-26.
Auth: every operation on this page needs a dashboard JWT. None of them accept an X-Api-Key. Every schedule is scoped to your tenant: an id that belongs to another tenant behaves exactly like one that doesn’t exist.
Before you start: what a scheduled run is (and isn’t)
Section titled “Before you start: what a scheduled run is (and isn’t)”A scheduled run is a headless workflow run. Plan your graph around these properties:
- There is no user message.
userMessageis""andconversationHistoryis[], the same starting contextrunWorkflowTaskdocuments (What the run’s context looks like atstart). Any facts the graph needs come from the schedule’s ownvariables, or fromaction/toolnodes that fetch them. - Nobody is waiting on a reply. Output from a
responsenode lands in the session’s transcript, but no chat client is subscribed to receive it. If the run should deliver something (post a digest, update a record, notify someone), the graph has to send it outward itself, for example with a terminalwebhooknode or anactionnode. - The agent decides which workflow runs, not the schedule. A schedule points at an
agentId. At every fire it runs whatever workflow is currently published on that agent, so republishing the agent’s workflow changes what the next fire runs. - Every fire is asynchronous. None of these mutations report whether a run succeeded. Read outcomes from
lastRunAt/lastRunStatuson the schedule and fromworkflowRuns(sessionId). See Observing runs below.
Cost controls: plan quota, spend cap, and auto-disable
Section titled “Cost controls: plan quota, spend cap, and auto-disable”A schedule has no human in the loop, which makes it the easiest way to spend money by accident. Three controls apply to every schedule:
- Plan quota. Your plan tier limits how many schedules the tenant can hold. PAUSED and AUTO_DISABLED schedules still count against the limit. Only
deleteScheduledWorkflowfrees a slot. Going over the limit rejectscreateScheduledWorkflowwith a message that names your limit. If your plan doesn’t include scheduled workflows at all, the message says so. See Pricing & Plans for how tiers work. - Monthly LLM spend cap. Every fire checks the tenant’s monthly spend cap before it starts anything. This is the same cap described under LLM API: Monthly spend cap. A fire blocked by the cap does not run, and it counts as a failed run toward the next control.
- Consecutive-failure auto-disable. When
maxConsecutiveFailuresfires fail in a row (default3, allowed range1–20), the schedule stops itself:statusbecomesAUTO_DISABLED,nextRunAtis cleared, and it stops firing. Any successful run resets the counter to0, so the breaker only trips on consecutive failures, never on an occasional one.
What counts as a failure: a run whose status is FAILED, plus any fire that cannot start. That includes the spend cap being reached, the agent not being ACTIVE, the agent having no published workflow, or the rendered session id being invalid. Runs that end COMPLETED, AWAITING_INPUT, or INTERRUPTED count as successes.
createScheduledWorkflow only checks that the agent exists in your tenant. It does not check that the agent is ACTIVE or has a published workflow. A schedule pointing at an agent in that state is accepted, then each of its fires fails until the breaker trips. Publish the agent’s workflow before you create the schedule. The unpublished-workflow trap applies here too.
Cron expressions and timezones
Section titled “Cron expressions and timezones”cronExpression must be a standard 5-field expression, minute hour day-of-month month day-of-week:
| Expression | Fires |
|---|---|
0 9 * * 1-5 | 09:00 every weekday |
*/15 * * * * | Every 15 minutes |
30 8 1 * * | 08:30 on the 1st of every month |
0 0 * * 0 | Midnight every Sunday |
0 12,18 * * * | 12:00 and 18:00 every day |
It is validated strictly when you save it, and a bad expression is rejected with a 400 so it never waits to fail at fire time:
- Exactly five fields. The 6-field form that adds seconds is rejected (a schedule that fires every second is exactly the runaway cost this feature is built to prevent). The minimum interval is one minute.
- No
@daily/@hourly-style aliases. Write the 5-field equivalent (0 0 * * *,0 * * * *).
timezone is an IANA zone name such as Asia/Kuala_Lumpur, Europe/London, or America/New_York. It defaults to UTC. The cron expression is evaluated in that zone, so 0 9 * * * with Asia/Kuala_Lumpur fires at 09:00 local time. An unknown zone name is rejected with a 400.
Session per fire: externalConversationIdTemplate
Section titled “Session per fire: externalConversationIdTemplate”Every fire runs on a real Wetel session, which is resolved the same way as for runWorkflowTask. The session’s clientExternalId comes from rendering externalConversationIdTemplate at fire time. The template supports three placeholders:
| Placeholder | Renders as |
|---|---|
{{scheduleId}} | The schedule’s id |
{{date}} | The fire’s calendar date, YYYY-MM-DD, in the schedule’s own timezone |
{{firedAt}} | The fire’s scheduled time as an ISO-8601 UTC timestamp |
The value you choose decides whether fires share memory:
- Default,
scheduled:{{scheduleId}}:{{firedAt}}: every fire gets its own fresh session with no history. Pick this unless you have a reason not to. scheduled:{{scheduleId}}: every fire reuses the same session, so each run can see the previous runs’ conversation history. Use this for cross-run memory.digest:{{date}}: fires on the same local day share a session, and a new day starts a new one.
The template allows only those three placeholders. Anything else in {{…}} is rejected at save time, because an unknown placeholder would otherwise render empty and quietly merge distinct runs onto one session. The rendered value must fit in 255 characters.
Observing runs
Section titled “Observing runs”The schedule row tells you when it last fired and how that fire ended:
lastRunAtis stamped when a fire starts.lastRunStatusis set to"running"when the fire starts, then replaced by the run’s outcome:"completed","failed","awaiting_input", or"interrupted". It is a plain lowercase string, not the uppercaseWorkflowRunStatusenum thatworkflowRunsreturns.nullmeans the schedule has never fired.consecutiveFailureCountshows how close the schedule is to auto-disabling.
For the full node trace of a particular fire, find the session and read its runs. A scheduled session’s clientExternalId is the rendered template, so with the default template every session for schedule 7 starts with scheduled:7:. You can find these through sessions(agentId:) and then call workflowRuns(sessionId):
query ScheduleRuns($sessionId: Int!) { workflowRuns(sessionId: $sessionId) { id status errorMessage nodeTrace context }}A failed fire that never started a run (spend cap reached, agent inactive, no published workflow) has no workflowRuns entry. In that case the schedule’s own lastRunStatus: "failed" and consecutiveFailureCount are the only record.
The ScheduledWorkflowDto shape
Section titled “The ScheduledWorkflowDto shape”Every query and most mutations below return this object.
| Field | Type | Description |
|---|---|---|
id | Int! | The schedule’s id. |
name | String! | Operator-facing label. |
agentId | Int! | The agent whose published workflow runs on each fire. |
cronExpression | String! | The 5-field cron expression. |
timezone | String! | IANA zone the cron is evaluated in. |
variables | JSON! | Flat map of scalars merged into every run’s starting context. {} when none. |
externalConversationIdTemplate | String! | Template for each fire’s session clientExternalId. See above. |
status | ScheduledWorkflowStatus! | ACTIVE, PAUSED, or AUTO_DISABLED. See below. |
maxConsecutiveFailures | Int! | Failures in a row before auto-disable. |
consecutiveFailureCount | Int! | Current run of consecutive failures. Resets to 0 on any success and on resume. |
lastRunAt | DateTime | When the most recent fire started. null if the schedule has never fired. |
lastRunStatus | String | Lowercase outcome of the most recent fire. See Observing runs. |
nextRunAt | DateTime | The next scheduled fire time. For display only. It is null unless status is ACTIVE. |
createdAt / updatedAt | DateTime! | Timestamps. |
ScheduledWorkflowStatus:
ACTIVE: registered and firing on its cron.PAUSED: stopped by an operator viapauseScheduledWorkflow. Settings are kept.AUTO_DISABLED: stopped automatically aftermaxConsecutiveFailuresconsecutive failed fires.
resumeScheduledWorkflow returns both PAUSED and AUTO_DISABLED schedules to ACTIVE.
scheduledWorkflows
Section titled “scheduledWorkflows”Relay-paginated list of your tenant’s schedules, every status included, ordered by id.
scheduledWorkflows(after: String, first: Int = 20): ScheduledWorkflowConnection!Arguments
Section titled “Arguments”| Argument | Type | Required | Description |
|---|---|---|---|
first | Int | No | Page size, 1–100, default 20. Out-of-range values are rejected with a 400. |
after | String | No | A previous page’s pageInfo.endCursor. |
Returns
Section titled “Returns”ScheduledWorkflowConnection!: edges { cursor node } (each node is a ScheduledWorkflowDto), pageInfo { endCursor hasNextPage }, and totalCount.
Example request
Section titled “Example request”A cheap health check that you could run on a timer to catch auto-disabled schedules:
query ScheduleHealth { scheduledWorkflows(first: 100) { totalCount edges { node { id name status lastRunAt lastRunStatus consecutiveFailureCount nextRunAt } } pageInfo { endCursor hasNextPage } }}scheduledWorkflow
Section titled “scheduledWorkflow”Fetches one schedule by id.
scheduledWorkflow(id: Int!): ScheduledWorkflowDtoReturns null (not an error) if the schedule doesn’t exist, has been deleted, or belongs to another tenant.
createScheduledWorkflow
Section titled “createScheduledWorkflow”Creates a schedule in the ACTIVE state. Its first fire happens at the next time the cron expression matches.
createScheduledWorkflow(input: CreateScheduledWorkflowInput!): ScheduledWorkflowDto!Arguments
Section titled “Arguments”All fields live under a single input object.
| Field | Type | Required | Description |
|---|---|---|---|
name | String! | Yes | Operator-facing label, max 100 characters. |
agentId | Int! | Yes | The agent whose published workflow runs on each fire. Must belong to your tenant. |
cronExpression | String! | Yes | Standard 5-field cron, max 100 characters. See Cron expressions and timezones. |
timezone | String | No | IANA zone name, max 50 characters. Default UTC. |
variables | JSON | No | Flat map of scalar facts merged into every run’s starting context. The rules are identical to runWorkflowTask’s variables: scalars only, reserved runner keys rejected. Pass it as a GraphQL variable, not an inline literal. |
externalConversationIdTemplate | String | No | Max 255 characters. Default scheduled:{{scheduleId}}:{{firedAt}} (a fresh session every fire). See Session per fire. |
maxConsecutiveFailures | Int | No | 1–20, default 3. |
A variables value that feeds an outbound write still needs a gate inside the graph, the same as for any task run. Put a condition node before the action and write someId > 0, never someId != 0. See Gate every id before it reaches an outbound call.
Returns
Section titled “Returns”The new ScheduledWorkflowDto, with status: ACTIVE and nextRunAt set to the first fire time.
Example request
Section titled “Example request”A weekday-morning digest in Kuala Lumpur time that reuses one session per local day:
mutation CreateSchedule($input: CreateScheduledWorkflowInput!) { createScheduledWorkflow(input: $input) { id name status cronExpression timezone nextRunAt externalConversationIdTemplate maxConsecutiveFailures }}{ "input": { "name": "Weekday morning ops digest", "agentId": 12, "cronExpression": "0 9 * * 1-5", "timezone": "Asia/Kuala_Lumpur", "variables": { "reportType": "daily_ops", "lookbackHours": 24 }, "externalConversationIdTemplate": "digest:{{date}}", "maxConsecutiveFailures": 3 }}Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "createScheduledWorkflow": { "id": 7, "name": "Weekday morning ops digest", "status": "ACTIVE", "cronExpression": "0 9 * * 1-5", "timezone": "Asia/Kuala_Lumpur", "nextRunAt": "2026-09-28T01:00:00.000Z", "externalConversationIdTemplate": "digest:{{date}}", "maxConsecutiveFailures": 3 } }}nextRunAt is in UTC: 09:00 in Kuala Lumpur (UTC+8) is 01:00 UTC.
Errors
Section titled “Errors”| Condition | Result |
|---|---|
Not 5 fields, or an @alias | 400: cronExpression must have exactly 5 fields (minute hour day-of-month month day-of-week) — received N. … |
| 5 fields but not a valid expression | 400: cronExpression "<expr>" is invalid: … |
| Unknown timezone | 400: timezone "<tz>" is not a valid IANA timezone (e.g. "UTC", "Asia/Kuala_Lumpur") |
Unsupported placeholder in externalConversationIdTemplate | 400, naming the placeholder |
variables breaks a rule (non-scalar, reserved key, too many keys, …) | 400, naming the offending key |
| Over your plan’s schedule limit, or the plan doesn’t include schedules | 400, naming your limit |
agentId doesn’t exist or belongs to another tenant (deliberately indistinguishable) | 404: Agent <id> not found |
| The scheduler couldn’t register the timer | 503: The schedule could not be registered with the job scheduler. Nothing was created — please retry. Safe to retry. |
updateScheduledWorkflow
Section titled “updateScheduledWorkflow”Partially updates a schedule. id is a separate argument, not a field inside input.
updateScheduledWorkflow(id: Int!, input: UpdateScheduledWorkflowInput!): ScheduledWorkflowDto!Arguments
Section titled “Arguments”UpdateScheduledWorkflowInput has the same fields as the create input (name, agentId, cronExpression, timezone, variables, externalConversationIdTemplate, maxConsecutiveFailures), and all of them are optional. They are validated the same way as on create.
- Omit a field to leave it unchanged. An explicit
nullis rejected. Nothing on this input can be cleared to “unset.” variablesreplaces the whole map. It is not merged, so send every key you want to keep.- Changing
cronExpression/timezoneon anACTIVEschedule re-registers its timer immediately. On aPAUSEDorAUTO_DISABLEDschedule, the new timing takes effect when you resume it.
Returns
Section titled “Returns”The updated ScheduledWorkflowDto.
Example request
Section titled “Example request”mutation MoveDigest($id: Int!, $input: UpdateScheduledWorkflowInput!) { updateScheduledWorkflow(id: $id, input: $input) { id cronExpression nextRunAt }}{ "id": 7, "input": { "cronExpression": "30 8 * * 1-5" } }Errors
Section titled “Errors”The same validation errors as createScheduledWorkflow, plus 404 (Scheduled workflow <id> not found) for an unknown or foreign id. If the timer couldn’t be re-registered, you get a 503 (The schedule was saved but could not be re-registered with the job scheduler — please retry the update.). Retrying the same update is safe.
pauseScheduledWorkflow
Section titled “pauseScheduledWorkflow”Stops a schedule from firing: status becomes PAUSED and nextRunAt is cleared. Every setting is kept, and the schedule still counts against your plan quota.
pauseScheduledWorkflow(id: Int!): ScheduledWorkflowDto!A fire that is already running when you pause finishes normally. If it fails, the schedule stays PAUSED, because a pause always wins over auto-disable.
resumeScheduledWorkflow
Section titled “resumeScheduledWorkflow”Re-activates a PAUSED or AUTO_DISABLED schedule. It re-registers the timer, sets status back to ACTIVE, recomputes nextRunAt, and resets consecutiveFailureCount to 0. Safe to retry.
resumeScheduledWorkflow(id: Int!): ScheduledWorkflowDto!Fails if the schedule’s agent no longer exists. If the timer couldn’t be re-registered, you get a 503; retry the same call.
Before resuming an AUTO_DISABLED schedule, fix whatever made it fail. Otherwise it will fail maxConsecutiveFailures more times and disable itself again. Look at lastRunStatus, the failing session’s workflowRuns, and whether the tenant has hit its spend cap.
deleteScheduledWorkflow
Section titled “deleteScheduledWorkflow”Deletes a schedule, unregisters its timer, and frees its plan quota slot. Returns true on success. Sessions and runs from earlier fires are not affected.
deleteScheduledWorkflow(id: Int!): Boolean!Name the operation when you call it from Apollo Sandbox. An anonymous mutation that returns a scalar can fail with a confusing syntax error:
mutation DeleteSchedule { deleteScheduledWorkflow(id: 7)}See also
Section titled “See also”- Workflows API:
runWorkflowTask: the on-demand, API-key version of the same headless run. - Workflows Overview: the graph a scheduled run executes.
webhooknode: the usual way a scheduled run delivers its result.- LLM API: Monthly spend cap: the cap every fire checks first.
- Pricing & Plans: how plan tiers set the schedule quota.