Skip to content

Workflow file format

Who this is for: people who read or edit workflow YAML, and AI assistants that write it.

Before you start: it helps to know the words workflow, step, action and artifact from What Liitora does.

A workflow is one YAML file shaped like a GitHub Actions workflow: when it runs (on:), then a list of steps:. Each step uses a ready-made action (uses:) or runs a small JavaScript function (run:). This page lists every key Liitora accepts. Unknown keys are refused, and the message says which key you probably meant.

This example is checked by Liitora’s validator. The comments explain each part.

# Required: a plain-language name and description.
name: Overdue devices follow-up
description: Every weekday morning, fetch the overdue devices from the ERP, email IT a CSV of them and open one service ticket per device.
on:
schedule:
- cron: "0 8 * * 1-5" # 08:00, Monday to Friday
timezone: Europe/Helsinki # local time, including summer time
calendar:
skip_on: [fi_public_holidays]
shift: next_business_day # a holiday run moves to the next business day
webhook:
endpoint: devices-changed # also run when this webhook is called
manual: true # people can start it with Run now
concurrency:
group: overdue-devices # never two runs at the same time
cancel_in_progress: false # a new run waits for the running one
expect: # alert if no run succeeded by 09:00
succeeds_by: "09:00"
on: weekdays
timezone: Europe/Helsinki
skip_on: [fi_public_holidays]
steps:
- id: fetch
name: Fetch overdue devices from the ERP
uses: platform/http.request@v1
connections:
api: internal-erp # the connection holds the address and sign-in
with:
method: GET
url: /v1/devices
query:
status: overdue
retry:
attempts: 3
backoff: exponential
on: [429, 5xx, timeout, network]
timeout_minutes: 5
- id: check
name: Check whether any devices are overdue
uses: platform/evaluate@v1
with:
expression: ${{ steps.fetch.outputs.json.devices.length > 0 }}
- id: report
name: Build a CSV of the overdue devices
if: ${{ steps.check.outputs.result }} # skipped when nothing is overdue
uses: platform/csv.build@v1
with:
rows: ${{ steps.fetch.outputs.json.devices }}
columns: [id, owner, due_date]
filename: overdue.csv
- id: email
name: Email the report to IT
uses: platform/email.send@v1
with:
to: [it@example.com]
subject: "Overdue devices on ${{ format_date(now(), '%Y-%m-%d', 'Europe/Helsinki') }}"
text: "${{ steps.report.outputs.rows }} devices are overdue. The list is attached."
attachments: ["${{ artifacts.overdue.csv }}"]
- id: tickets
name: Open a service ticket for each device
needs: [report] # runs next to email, not after it
uses: platform/http.request@v1
connections:
api: service-desk
strategy:
for_each: ${{ steps.fetch.outputs.json.devices }}
max_parallel: 3
fail_fast: false
with:
method: POST
url: /api/tickets
json:
title: "Return device ${{ each.item.id }}"
owner: ${{ each.item.owner }}
- id: notify_failure
name: Tell the IT channel that the follow-up failed
needs: [email, tickets]
if: ${{ failure() }} # runs only when an earlier step failed
uses: platform/webhook.send@v1
connections:
target: it-chat
with:
url: /hooks/it
json:
text: "The overdue devices follow-up failed. Open Liitora to see the run."

Liitora turns this into the sentence people see on the approval page: “Every weekday at 08:00 Helsinki time, when the devices-changed webhook is called, or when someone starts it by hand: fetch overdue devices from the ERP, then check whether any devices are overdue. If that check passes, build a CSV of the overdue devices, then email the report to IT and open a service ticket for each device…”

Key Required What it is
name yes A plain-language name, one line, at most 120 characters. Names that look like ids, such as step1, are refused.
description yes What the workflow does and why, at most 2000 characters.
on no The triggers. Without any trigger you get the warning no_triggers: the workflow can then only be called by another workflow.
runs-on no Runs the workflow on your own runners: self-hosted/<group>. Without it, the workflow runs on Liitora. See Runs on.
concurrency no Keeps runs from overlapping. See Concurrency.
expect no A freshness check: the workflow must succeed by a time of day. See Expect.
steps yes The list of steps, at least 1 and at most 100. See Steps.

on: says what starts a run. You can combine several triggers in one workflow.

Key Value Starts a run when
schedule a list of schedules the time comes. See Schedule.
webhook { endpoint: <name> } another system calls the webhook endpoint with that name.
workflow_run { workflow: <name>, types: [...] } another workflow of your company finishes.
manual true, {} or empty a person selects Run now in the app.
api true, {} or empty a program starts it through the Liitora API.

manual: false and api: false are the same as leaving the key out. Liitora checks both keys in the active version:

  • Without manual, Run now is refused with manual_start_not_allowed.
  • Without api, a production run started with an API token or by an AI assistant is refused with api_start_not_allowed.

Test runs and dry runs always work, so you can try a workflow before you decide how it starts. To allow a start, add the key, save and activate the new version.

Each entry has these keys. A workflow can have at most 10 schedules.

Key Required Default What it is
cron yes none Five fields: minute, hour, day of month, month, day of week.
timezone no UTC An IANA time zone name, such as Europe/Helsinki or Europe/Stockholm.
calendar no none Holidays and blackout days. See Calendar.
misfire no skip What happens to times missed while Liitora couldn’t run the schedule: skip or run_once. See Missed runs below.

Cron rules:

  • Exactly five fields. Seconds, years and shortcuts such as @daily are refused.
  • Ranges (1-5), lists (1,3,5) and steps (*/15) work. Day of week 0 is Sunday and 1-5 is Monday to Friday.
  • Runs must be at least your plan’s minimum interval apart (5 minutes unless your plan says otherwise). Liitora checks the shortest gap, including summer time changes.
  • Quote the pattern ("0 8 * * 1-5"), because * has a meaning in YAML.
Pattern Runs
"0 8 * * 1-5" at 08:00 on weekdays
"30 6 * * *" at 06:30 every day
"0 7 * * 1" at 07:00 on Mondays
"*/15 * * * *" every 15 minutes
"0 9 1 * *" at 09:00 on the first day of each month

Summer time. Times are local to timezone. When clocks move forward, a fixed time in the missing hour (for example 03:30) runs once, at the first valid moment after it. When clocks move back, a fixed time in the repeated hour runs once, the first time. Interval schedules such as */15 follow real time: they skip the missing hour and run twice in the repeated hour.

Missed runs. If a run could not start at its time for more than 5 minutes (for example during maintenance), it counts as missed and an alert is recorded. With misfire: skip (the default) missed times don’t run. With misfire: run_once, Liitora makes one catch-up run for all of them, for the latest missed time. Use run_once when the work must happen even if it is late, such as a nightly export.

on:
schedule:
- cron: "0 2 * * *"
timezone: Europe/Helsinki
misfire: run_once

calendar: belongs to one schedule.

Key Default What it is
skip_on none A list of calendars whose days block the run: fi_public_holidays, se_public_holidays, weekends, tenant_blackouts. Each at most once.
shift none none skips a blocked run. next_business_day runs it at the same local time on the next business day instead.

A business day is Monday to Friday and not blocked by skip_on. If several runs move to the same day and time, they become one run. shift: next_business_day without skip_on gives the warning shift_without_skip_on. The how-to Use calendars and holidays lists which holidays each calendar has.

on:
webhook:
endpoint: devices-changed

endpoint is the name of a webhook endpoint in your company: lowercase letters, digits and -. The run gets the delivery in trigger: trigger.body (the parsed JSON, the form fields or the text; in JSON, whole numbers of 2^53 (9,007,199,254,740,992) or more come as text so no digit is lost), trigger.headers, trigger.content_type, trigger.received_at, trigger.event_id and trigger.idempotency_key. A form body is always split into its fields when the run starts, even without parse, and trigger.raw_body is the text as sent. See Set up a webhook.

parse reads a text body for you, so steps can use its fields directly:

on:
webhook:
endpoint: orders-in
parse: xml # or csv, or form
parse trigger.body becomes
xml the document as xml.parse gives it, such as trigger.body.order["@id"]
csv a list of rows, one object per row, with the first row as the column names
form the form fields, with a repeated field as a list

trigger.raw_body is always the text as sent, so a script can check a signature or read it another way. The body is read when the run starts, with the same rules and limits as ctx.formats in scripts. If it can’t be read (for example the XML is not well-formed, a form has a broken % code, or the sender sent JSON), the run fails with format_error and is not retried. Leave parse out to get JSON as an object and other text as it is.

receive: runner lets the workflow’s runner receive the webhook instead of Liitora, so the body and the headers never enter Liitora. It needs runs-on: self-hosted/<group>. The endpoint and its secret are then set on the runner, not in Liitora, and the run gets the delivery in trigger as above. Leave receive out, or write receive: liitora, to receive the webhook on Liitora. See Receive webhooks inside your network.

runs-on: self-hosted/on-prem
on:
webhook:
endpoint: erp-orders
receive: runner
on:
workflow_run:
workflow: nightly-device-sync
types: [succeeded]
Key Default What it is
workflow none The other workflow’s name (its slug) in your company.
types [succeeded] Which results start this workflow: succeeded, failed, cancelled or any. any can’t be combined with others.

The run gets trigger.workflow_run with id, workflow_id, result and finished_at of the run that finished. Test runs never start other workflows.

Besides the trigger’s own values, every run has trigger.kind, trigger.now (the time the run is for), trigger.scheduled_for, trigger.run_id, trigger.environment (production or test), trigger.is_test and trigger.payload. A scheduled run also has trigger.cron, trigger.timezone and trigger.slot, and trigger.shifted_from when a calendar moved it. A run started with Run now gets the JSON input you typed.

runs-on: self-hosted/<group> sends every run of the workflow to the runners of that runner group in your own network. Only runners of the group ever run it: when none is online, runs wait and admins get the runner_unavailable alert. They never move to Liitora’s own workers.

  • The group name has 1 to 63 lowercase letters, digits and hyphens, and starts with a letter or digit. Any other value is refused with invalid_runs_on or invalid_runner_group.
  • The group must exist when you save the workflow (runner_group_missing).
  • Secrets come from the runner’s own environment, and some features are not available on runners yet.

See Run automations in your own environment.

concurrency:
group: overdue-devices
cancel_in_progress: false
Key Default What it is
group none Runs with the same group never run at the same time. It may use ${{ trigger... }} and ${{ vars... }}, for example sync-${{ trigger.body.region }}. It can’t be empty.
cancel_in_progress false false: a new run waits until the running one ends. true: the running one is cancelled.

expect: is a freshness check. If no run of this workflow succeeded by the deadline, an alert is raised, whether the run failed, was late or never started. See Set freshness expectations.

Key Required Default What it is
succeeds_by yes none The deadline, HH:MM local time, 00:00 to 23:59. Quote it: "09:00".
from no "00:00" When the window opens, HH:MM. A time not before succeeds_by opens the window on the previous day, for example from: "18:00" with succeeds_by: "09:00".
on no daily Which days have a deadline: daily, weekdays, weekends, or a list such as [mon, wed, fri].
timezone no UTC An IANA time zone.
skip_on no none Calendars whose days have no deadline, as in Calendar.

A success exactly at the deadline counts. Test runs never count.

Key Required Default What it is
id yes none A short id: starts with a lowercase letter, then lowercase letters, digits, _ or -, at most 64 characters. Unique in the workflow. Other steps use it in needs: and expressions.
name yes none A plain-language name on one line, at most 120 characters.
uses one of uses and run none The action to run, such as platform/http.request@v1. See Action references.
run one of uses and run none An inline JavaScript module, at most 64 KB. See Host API for scripts.
with no none The inputs of the action (or of the script). Values may contain expressions. Checked against the action’s input schema when you save.
connections no none Maps the action’s connection slots to your connections, for example api: internal-erp.
needs no the step above One step id or a list. needs: [] means no dependency.
if no success() A condition. See Conditions and dependencies.
strategy no none Runs the step once per item. See For each.
retry no no retries Tries a failed step again. See Retry.
timeout_minutes no 15 Stops one attempt after this many minutes, 1 to 60.
continue_on_error no false true: a failure of this step does not fail the run, and later steps treat it as a success.
wait no true Only for workflow calls (uses: workflow/...): true waits for the called workflow and returns its result; false starts it and moves on.

A step’s result is steps.<id>.outputs. For built-in actions the fields are in Built-in actions. For a run: script, the object it returns is its outputs. steps.<id>.result is success, failure, skipped or cancelled.

Files a step makes (artifacts) are available to later steps as artifacts.<file name>, for example ${{ artifacts.overdue.csv }}. Each artifact has id, name, content_type and size_bytes. File names are unique within a run.

- id: fetch
name: Fetch invoices changed since the last sync
run: |
export default async function run(ctx, input) {
const erp = ctx.connection("erp");
const res = await erp.get("/v1/invoices", { query: { since: input.since } });
if (!res.ok) throw new Error(`the ERP answered ${res.status}`);
const items = res.json().items;
if (items.length === 0) return ctx.skip("no new invoices");
return { items };
}
connections:
erp: visma
with:
since: ${{ steps.cursor.outputs.value }}

input holds the step’s with: values. Logic that other workflows could reuse belongs in an action in your library, with its own schemas and tests.

Steps run in the order you write them, unless needs: says otherwise:

  • A step without needs: waits for the step above it. The first step starts at once.
  • needs: [a, b] waits for both a and b. Steps that don’t depend on each other run side by side.
  • needs: [] starts the step at once.
  • Unknown ids, a step that needs itself, a repeated id and loops are errors. A loop is shown as a -> b -> a.

if: decides whether a step runs. You can write it with or without ${{ }}, but not mix text and ${{ }} (that would always be true).

  • No if: means success(): the step runs only when every step it depends on succeeded.
  • An if: without a status function means success() && (<your condition>). Your condition is not evaluated when an earlier step failed or was skipped.
  • So a skipped or failed step also skips the steps that depend on it, as in GitHub Actions. To run anyway, use a status function.

These work only in if:.

Function True when
success() every earlier step this step depends on (directly or through others) succeeded, and the run is not cancelled
failure() an earlier step this step depends on failed
always() always, even after failures or a cancel
cancelled() the run was cancelled

Useful patterns:

if: Runs when
${{ failure() }} something earlier failed, for example to send an alert
${{ always() }} always, for example to clean up
${{ !failure() && !cancelled() }} nothing failed, even if some steps were skipped
${{ always() && needs.check.result == 'skipped' }} a direct dependency was skipped

A step with continue_on_error: true that fails counts as a success for the steps after it. In a cancelled run, steps without always() or cancelled() are marked cancelled.

strategy:
for_each: ${{ steps.fetch.outputs.json.devices }}
max_parallel: 3
fail_fast: false
Key Default What it is
for_each none An expression that gives a list. The step runs once per item, at most 1000 items.
max_parallel your plan’s cap (5 by default) How many items run at the same time, 1 to 20, and never more than your plan allows.
fail_fast true true: after the first failed item no new items start and the rest are cancelled. false: every item runs.
quarantine false true: an item that still fails after its retries is set aside (quarantined) instead of failing the step. Every item runs, later steps run, and the run ends as failed with items_quarantined. If every item fails, the step fails as usual. See Replay quarantined items.

Inside with: of that step, each.item is the current item and each.index its position (from 0). The step’s outputs are a list with one entry per item, in order, and null for items that did not succeed. An empty list is a success.

retry:
attempts: 3
backoff: exponential
on: [429, 5xx, timeout, network]
Key Default What it is
attempts none (required) Total attempts including the first, 1 to 10.
backoff exponential The wait between attempts: fixed, linear or exponential, from 1 second up to 60 seconds.
on [429, 5xx, timeout, network] Which failures are retried: an HTTP status such as 429 or 503, 4xx, 5xx, timeout or network. Can’t be empty.

Wrong input, refused calls and limits that were reached are never retried. Every attempt sends the same Idempotency-Key, so the other system can tell a retry from a new request.

A step can call another workflow of your company:

- id: tickets
name: Open the service tickets
uses: workflow/acme/open-service-tickets@v3
wait: true
with:
devices: ${{ steps.fetch.outputs.json.devices }}
  • The owner is your company’s short name. You can only call your own workflows.
  • In production, the called workflow’s active version runs, never an unapproved one. Test runs call the version you pin.
  • wait: true (the default) waits and returns the called run’s result, run_id and status. wait: false returns run_id and status: queued at once.
  • Calls can be nested 5 levels deep, and a workflow can’t call itself through others.
  • A waiting call keeps a run slot busy, so your plan must allow at least 2 runs at the same time.

${{ ... }} computes a value. You can use it in with:, if:, strategy.for_each and concurrency.group.

  • A value that is only one expression keeps its type: rows: ${{ steps.fetch.outputs.json.devices }} stays a list.
  • Text around an expression makes text: "Found ${{ steps.report.outputs.rows }} rows". There, null becomes empty text and lists or objects become compact JSON.
Name What it is
trigger The run’s trigger payload (see Triggers).
steps.<id>.outputs, steps.<id>.result A step that runs before this one (directly or through others).
needs.<id>.outputs, needs.<id>.result A direct dependency listed in needs:.
artifacts.<file name> A file made earlier in this run. The longest matching file name wins, so artifacts.overdue.csv finds overdue.csv. A run can’t have two files where one name is the other plus a dot and more, such as report and report.id, so a name always means one file.
each.item, each.index The current item, only in with: of a for-each step.
vars.<name> A company variable, such as vars.report_to. A name that doesn’t exist is empty (null). Runs read the values when they start.

In concurrency.group only trigger and vars are allowed.

  • Literals: null, true, false, numbers, 'single quoted' (write '' for a quote) and "double quoted" text.
  • Access: a.b, a[0], a['key with spaces'], a.0. .length works on lists and text.
  • Operators, from weakest to strongest: ? :, ||, &&, == !=, < <= > >=, + -, * / %, ! and minus.
  • Pipe: x | f(a) is the same as f(x, a), for example ${{ steps.fetch.outputs.json.name | lower() }}.

How values behave:

  • A missing property is null, not an error.
  • && and || return one of their operands, as in GitHub Actions. False values are false, 0, '' and null.
  • == compares values exactly: numbers by value, no conversion between types.
  • < and the other comparisons work between two numbers or two texts.
  • + adds numbers or joins two texts. Dividing by zero is an error.
  • Whole numbers are exact up to 64 bits, so a large ID such as 9007199254740993 keeps every digit through functions, arithmetic and comparisons. A result that is too large for that, or has a fraction, is a decimal number. A result too large for any number is an error.
  • Limits: an expression is at most 4096 characters, 32 levels deep and 100 000 evaluation steps. Texts are at most 1 MB and lists at most 10 000 items. One expression can work on at most 64 MB of data in total. An expression that would pass a limit stops with evaluation_error as soon as it gets there. To handle more data, refer only to the fields you need, or use a platform/transform step.
Function Returns Example
length(value) Items in a list, characters in text or keys in an object; 0 for null. length(steps.fetch.outputs.json.devices)
contains(search, item) True when a list has an equal item, or text contains the text (ignoring case). contains(trigger.headers['x-event'], 'device')
startsWith(text, prefix) True when the text starts with the prefix (ignoring case). startsWith(trigger.body.id, 'INV-')
endsWith(text, suffix) True when the text ends with the suffix (ignoring case). endsWith(trigger.body.email, '@example.com')
join(list, separator = ',') The items as text, with the separator between them. join(steps.fetch.outputs.json.ids, ', ')
split(text, separator) The text cut into a list. split('a;b;c', ';')
toJSON(value) The value as compact JSON text. toJSON(trigger.body)
fromJSON(text) JSON text parsed into a value. fromJSON(steps.read.outputs.body)
format(template, args...) {0}, {1} and so on replaced by the arguments. Write {{ and }} for braces. At most 15 arguments. format('{0} of {1}', 3, 10)
default(value, fallback) The value, or the fallback when the value is null or empty text. default(trigger.body.region, 'fi')
lower(text) The text in lower case. lower(trigger.body.status)
upper(text) The text in upper case. upper('fi')
trim(text) The text without spaces at the start and end. trim(trigger.body.name)
url_encode(text) The text made safe for one part of a URL, such as a path segment or a query value. Every character except the letters a to z and A to Z, the digits and -, ., _ and ~ is percent-encoded, so ?, &, / and % stay inside the value. format('/customers/{0}', url_encode(trigger.body.id))
now() The current time, RFC 3339 in UTC. Frozen in tests. now()
plus_days(date, days, timezone?) The date moved forward by whole calendar days. With a time zone, days are counted in that zone across summer time. plus_days(now(), 7, 'Europe/Helsinki')
minus_days(date, days, timezone?) The date moved back by whole calendar days. minus_days(now(), 1)
plus_hours(date, hours) The time moved forward by hours. plus_hours(trigger.now, 2)
minus_hours(date, hours) The time moved back by hours. minus_hours(now(), 24)
format_date(date, format, timezone?) The date formatted with strftime codes such as %Y-%m-%d, in the time zone if one is given. format_date(now(), '%d.%m.%Y', 'Europe/Helsinki')
lookup('table', key, column?, fallback?) The row of a lookup table whose key (first column) is the key as exact text, as an object of column to text. With a column, only that cell. An unknown key is an error unless a fallback is given. The table name must be in quotes. Runs read the version that was current when they started. lookup('cost_centers', trigger.body.code, 'name')

Use url_encode whenever a value you don’t control goes into a connection path. Without it, an ID such as 42?admin=1 or a/b would change which address is called. A path whose value is encoded more than once to hide .. is refused.

Dates are RFC 3339 texts (2026-10-05T07:00:00Z) or plain dates (2026-10-05), years 1 to 9999. The status functions success(), failure(), always() and cancelled() are described in Status functions.

  • Syntax, unknown names and functions, and the number of arguments.
  • lookup() must name its table in quotes. A table that doesn’t exist yet is a warning (unknown_lookup_table), not an error, so you can create it afterwards.
  • steps.<id> must exist, must not be the step itself, and must run before it. needs.<id> must be a direct dependency.
  • each only in with: of a for-each step, status functions only in if:.
  • Values are checked against the action’s input schema: missing and unknown inputs, types, allowed values and lengths. An expression’s type is checked too, when it is known. References to another step’s outputs are checked when that action lists its outputs.
  • One document per file, at most 512 KB, at most 32 levels deep and 50 000 values.
  • Anchors, aliases (&x, *x), tags (!!str) and complex keys are refused. Repeated keys are refused.
  • Liitora reads YAML 1.2, so on: stays a key and is not read as true.
  • Keys use underscores: timeout_minutes, not timeout-minutes.
  • Every problem is reported with its line and column, what went wrong and how to fix it.

Every message has a code, a place, what went wrong and how to fix it. Errors stop the save. Warnings are kept with the version.

Code What it means
yaml_syntax The file is not valid YAML.
yaml_empty The file is empty.
yaml_multiple_documents The file has more than one YAML document (---).
yaml_too_large The file is larger than 512 KB or has too many values.
yaml_too_deep The file is nested more than 32 levels deep.
yaml_unsupported The file uses anchors, aliases, tags or complex keys.
yaml_duplicate_key A key appears twice in the same place.
not_a_mapping The file (or a part of it) is not a set of keys and values.
unknown_key A key Liitora doesn’t know. The message suggests the right one, also for GitHub Actions keys.
missing_key A required key is missing, such as cron in a schedule.
invalid_value A value has the wrong type, for example text where a number belongs.
missing_name The workflow or a step has no name:.
missing_description The workflow has no description:.
name_not_plain_language A name looks like an id, such as step1. Use words people understand.
text_too_long A name or the description is too long.
invalid_text A name spans several lines or has control characters.
no_steps The workflow has no steps.
too_many_steps More than 100 steps.
no_triggers Warning: nothing starts the workflow, so only another workflow can call it.
too_many_schedules More than 10 schedules.
invalid_cron The schedule is not a five-field cron pattern.
invalid_timezone Not an IANA time zone. The message suggests close names.
schedule_too_frequent Runs would be closer together than your plan allows.
unknown_calendar skip_on names a calendar that doesn’t exist.
duplicate_calendar A calendar is listed twice.
unknown_shift shift is not none or next_business_day.
unknown_misfire misfire is not skip or run_once.
shift_without_skip_on Warning: shift: next_business_day has no skip_on, so nothing moves.
invalid_time_of_day succeeds_by or from is not HH:MM between 00:00 and 23:59.
invalid_expect_days expect.on is not a preset or a list of different day names.
invalid_webhook_endpoint The webhook endpoint name has characters other than lowercase letters, digits and -.
unknown_webhook_parse parse is not xml, csv or form.
unknown_webhook_receiver receive is not liitora or runner.
webhook_receiver_needs_runner receive: runner on a workflow without runs-on: self-hosted/<group>.
invalid_workflow_run The workflow_run trigger names no valid workflow, or types is empty or mixes any with others.
empty_concurrency_group concurrency.group is empty.
missing_id A step has no id:.
invalid_id A step id breaks the id rules.
duplicate_id Two steps have the same id.
missing_action A step has neither uses: nor run:.
both_uses_and_run A step has both uses: and run:.
empty_run run: is empty.
run_too_large run: is larger than 64 KB.
script_expression A platform/transform script uses a ${{ }} expression. Pass the data through input instead.
html_expression The html of a platform/email.send step uses a ${{ }} expression. Store the HTML as a template and pass the values through data, or send plain text.
invalid_action_ref uses: is not a valid action reference.
unknown_need needs: names a step that doesn’t exist.
self_need A step needs itself.
duplicate_need A step id is listed twice in needs:.
dependency_cycle Steps wait for each other in a loop.
chain_too_deep The longest chain of steps is longer than 50.
invalid_retry_attempts retry.attempts is below 1 or above 10.
empty_retry_on retry.on is an empty list.
invalid_retry_on retry.on has something other than a status, 4xx, 5xx, timeout or network.
invalid_timeout timeout_minutes is below 1 or above 60.
invalid_max_parallel max_parallel is below 1 or above the limit.
for_each_not_a_list for_each does not give a list.
wait_without_workflow wait: is set on a step that doesn’t call a workflow.
invalid_connection_name A connection name has characters that are not allowed.
invalid_connection_slot A connection slot name has characters that are not allowed.
unknown_connection_slot The action has no connection slot with that name.
missing_connection The action needs a connection for a slot, and none is given.
unknown_input with: has an input the action doesn’t have. The message suggests close names.
missing_input A required input is not set.
invalid_input An input breaks the action’s rules: allowed values, length or number range.
input_type_mismatch An input has the wrong type, such as a number where text belongs.
invalid_expression An expression has a syntax error.
unknown_name An expression uses a name that doesn’t exist.
unknown_function An expression calls a function that doesn’t exist.
wrong_argument_count A function gets too few or too many arguments.
status_function_outside_if success(), failure(), always() or cancelled() is used outside if:.
root_not_allowed A name is not available in that place, such as steps in concurrency.group.
unknown_step_reference steps.<id> names a step that doesn’t exist.
self_reference A step reads its own result.
step_not_upstream A step reads a step that doesn’t run before it. Add it to needs:.
not_a_need needs.<id> is used for a step that is not in needs:.
unknown_step_property Only outputs and result exist under a step.
unknown_output The action has no output with that name. The message lists the outputs.
each_outside_for_each each is used in a step without strategy.for_each.
unknown_each_property Only each.item and each.index exist.
evaluation_error An expression could not be computed, for example a division by zero.
workflow_call_cycle Workflows call each other in a loop.
workflow_call_too_deep Workflow calls are nested too deep.

When you save, Liitora also checks the scripts and scans the values for passwords and keys. Those messages are listed in Host API for scripts. Saving can also warn that an action, a called workflow or a connection doesn’t exist yet (unknown_action, unknown_workflow, unknown_connection). You can save such a draft, but you can’t activate it until they exist.