Skip to content

Scheduled Workflows

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. userMessage is "" and conversationHistory is [], the same starting context runWorkflowTask documents (What the run’s context looks like at start). Any facts the graph needs come from the schedule’s own variables, or from action/tool nodes that fetch them.
  • Nobody is waiting on a reply. Output from a response node 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 terminal webhook node or an action node.
  • 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/lastRunStatus on the schedule and from workflowRuns(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:

  1. Plan quota. Your plan tier limits how many schedules the tenant can hold. PAUSED and AUTO_DISABLED schedules still count against the limit. Only deleteScheduledWorkflow frees a slot. Going over the limit rejects createScheduledWorkflow with 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.
  2. 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.
  3. Consecutive-failure auto-disable. When maxConsecutiveFailures fires fail in a row (default 3, allowed range 1–20), the schedule stops itself: status becomes AUTO_DISABLED, nextRunAt is cleared, and it stops firing. Any successful run resets the counter to 0, 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.

cronExpression must be a standard 5-field expression, minute hour day-of-month month day-of-week:

ExpressionFires
0 9 * * 1-509:00 every weekday
*/15 * * * *Every 15 minutes
30 8 1 * *08:30 on the 1st of every month
0 0 * * 0Midnight 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:

PlaceholderRenders 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.

The schedule row tells you when it last fired and how that fire ended:

  • lastRunAt is stamped when a fire starts.
  • lastRunStatus is 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 uppercase WorkflowRunStatus enum that workflowRuns returns. null means the schedule has never fired.
  • consecutiveFailureCount shows 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.

Every query and most mutations below return this object.

FieldTypeDescription
idInt!The schedule’s id.
nameString!Operator-facing label.
agentIdInt!The agent whose published workflow runs on each fire.
cronExpressionString!The 5-field cron expression.
timezoneString!IANA zone the cron is evaluated in.
variablesJSON!Flat map of scalars merged into every run’s starting context. {} when none.
externalConversationIdTemplateString!Template for each fire’s session clientExternalId. See above.
statusScheduledWorkflowStatus!ACTIVE, PAUSED, or AUTO_DISABLED. See below.
maxConsecutiveFailuresInt!Failures in a row before auto-disable.
consecutiveFailureCountInt!Current run of consecutive failures. Resets to 0 on any success and on resume.
lastRunAtDateTimeWhen the most recent fire started. null if the schedule has never fired.
lastRunStatusStringLowercase outcome of the most recent fire. See Observing runs.
nextRunAtDateTimeThe next scheduled fire time. For display only. It is null unless status is ACTIVE.
createdAt / updatedAtDateTime!Timestamps.

ScheduledWorkflowStatus:

  • ACTIVE: registered and firing on its cron.
  • PAUSED: stopped by an operator via pauseScheduledWorkflow. Settings are kept.
  • AUTO_DISABLED: stopped automatically after maxConsecutiveFailures consecutive failed fires.

resumeScheduledWorkflow returns both PAUSED and AUTO_DISABLED schedules to ACTIVE.

Relay-paginated list of your tenant’s schedules, every status included, ordered by id.

scheduledWorkflows(after: String, first: Int = 20): ScheduledWorkflowConnection!
ArgumentTypeRequiredDescription
firstIntNoPage size, 1–100, default 20. Out-of-range values are rejected with a 400.
afterStringNoA previous page’s pageInfo.endCursor.

ScheduledWorkflowConnection!: edges { cursor node } (each node is a ScheduledWorkflowDto), pageInfo { endCursor hasNextPage }, and totalCount.

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
}
}
}

Fetches one schedule by id.

scheduledWorkflow(id: Int!): ScheduledWorkflowDto

Returns null (not an error) if the schedule doesn’t exist, has been deleted, or belongs to another tenant.

Creates a schedule in the ACTIVE state. Its first fire happens at the next time the cron expression matches.

createScheduledWorkflow(input: CreateScheduledWorkflowInput!): ScheduledWorkflowDto!

All fields live under a single input object.

FieldTypeRequiredDescription
nameString!YesOperator-facing label, max 100 characters.
agentIdInt!YesThe agent whose published workflow runs on each fire. Must belong to your tenant.
cronExpressionString!YesStandard 5-field cron, max 100 characters. See Cron expressions and timezones.
timezoneStringNoIANA zone name, max 50 characters. Default UTC.
variablesJSONNoFlat 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.
externalConversationIdTemplateStringNoMax 255 characters. Default scheduled:{{scheduleId}}:{{firedAt}} (a fresh session every fire). See Session per fire.
maxConsecutiveFailuresIntNo1–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.

The new ScheduledWorkflowDto, with status: ACTIVE and nextRunAt set to the first fire time.

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: customer
{
"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.

ConditionResult
Not 5 fields, or an @alias400: cronExpression must have exactly 5 fields (minute hour day-of-month month day-of-week) — received N. …
5 fields but not a valid expression400: cronExpression "<expr>" is invalid: …
Unknown timezone400: timezone "<tz>" is not a valid IANA timezone (e.g. "UTC", "Asia/Kuala_Lumpur")
Unsupported placeholder in externalConversationIdTemplate400, 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 schedules400, 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 timer503: The schedule could not be registered with the job scheduler. Nothing was created — please retry. Safe to retry.

Partially updates a schedule. id is a separate argument, not a field inside input.

updateScheduledWorkflow(id: Int!, input: UpdateScheduledWorkflowInput!): ScheduledWorkflowDto!

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 null is rejected. Nothing on this input can be cleared to “unset.”
  • variables replaces the whole map. It is not merged, so send every key you want to keep.
  • Changing cronExpression/timezone on an ACTIVE schedule re-registers its timer immediately. On a PAUSED or AUTO_DISABLED schedule, the new timing takes effect when you resume it.

The updated ScheduledWorkflowDto.

mutation MoveDigest($id: Int!, $input: UpdateScheduledWorkflowInput!) {
updateScheduledWorkflow(id: $id, input: $input) {
id
cronExpression
nextRunAt
}
}
{ "id": 7, "input": { "cronExpression": "30 8 * * 1-5" } }

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.

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.

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.

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)
}