Skip to content

Built-in actions

Who this is for: people and AI assistants who write workflow steps with uses: platform/....

Before you start: read Steps in the workflow format.

Built-in actions are part of Liitora. Use them in any workflow as uses: platform/<name>@v1. They run inside Liitora, and they reach other systems only through Liitora’s safe HTTP client and your connections. The Inputs and Outputs tables below are made from the same JSON Schemas that Liitora uses to check your workflow when you save it and every step when it runs. You also find them in the app under Library, and your AI assistant reads them with the MCP tool get_action.

Action What it does Connection slot
http.request Calls an HTTP API. api (optional)
evaluate Computes a value, for example a check for if:. none
transform Reshapes data with a small JavaScript function. none
json.map Picks and renames fields. none
csv.build Builds a CSV file from rows. none
csv.parse Reads CSV text or a CSV file into rows. none
pdf.render Renders an HTML template to a PDF file. none
zip Packs files into one zip file. none
unzip Unpacks a zip file into one file per entry. none
email.send Sends an email from your company’s sender. none
webhook.send Sends an outgoing webhook, optionally signed. target (optional)
state.get Reads a small saved value, such as a sync cursor. none
state.set Saves a small value, such as a sync cursor. none
sync.diff Finds created, updated and deleted records, against a target or the last successful run. none
xml.parse Reads XML text or an XML file into a value. none
xml.build Builds XML text or an XML file from a value. none
xml.split Reads the repeating records of an XML document as a list. none
xlsx.read Reads the rows of one sheet of an Excel workbook. none
xlsx.write Builds an Excel workbook from rows. none
fixed.parse Reads fixed-width records from text or a file. none
fixed.build Builds fixed-width records as text or a file. none
wait Pauses the run for a few seconds. none
ai.generate Asks your own AI model and checks the answer against a schema. model
files.list Lists files on SFTP or S3. store
files.download Downloads a file into an artifact. store
files.upload Writes a file. store
files.move Moves or renames a file. store
files.delete Deletes a file. store
  • Required says whether you must set the input. Some actions also need one input out of a group; the line under the table says so.
  • Rules are checked when you save the workflow and again when the step runs. A value that breaks a rule fails the step with invalid_input, and the message names the field without showing its value.
  • Output fields are what later steps read as ${{ steps.<id>.outputs.<field> }}.
  • Files an action makes are artifacts. Later steps use them as ${{ artifacts.<file name> }}.

platform/http.request@v1 makes one HTTP call. Give url as a full https:// address, or as a path such as /v1/devices on the connection bound to the api slot. With a connection, Liitora adds the address and the sign-in for you.

Field Type Required Rules What it is
method string no one of GET, POST, PUT, PATCH, DELETE, HEAD; default "GET" HTTP method.
url string yes not empty Absolute https URL, or a path such as /v1/devices when the api connection is set.
query object no none Query parameters.
headers object no none Extra request headers. Authentication comes from the connection; never put secrets here.
json any no none Request body sent as JSON.
body string no none Raw request body.
timeout_seconds integer no at least 1; at most 300; default 30 Request timeout.
expect_status list of integer no each item: at least 100; at most 599 Statuses treated as success; others fail the step.

Other fields are refused.

Field Type Required Rules What it is
status integer yes none Response status.
headers object yes none Response headers.
json any no none Response body parsed as JSON, when it is JSON.
body string no none Response body as text.
body_encoding string no one of text, base64 base64 when body holds a binary response encoded as base64.

Other fields are refused.

Rules:

  • Send the body as json or as body, not both.
  • Without expect_status, any status other than 2xx fails the step. With it, only the listed statuses succeed. A failure with 429 or a 5xx status can be retried with retry:.
  • timeout_seconds is 1 to 300 seconds (default 30).
  • Addresses with a user name or password in them (https://user:pass@...) are refused.
  • You can’t set the headers Authorization, Proxy-Authorization, Cookie or X-Api-Key. Sign-in comes from the connection. Transport headers such as Host and Content-Length are refused too.
  • Header values are one line. At most 50 extra headers, and the request body is at most 10 MB.
  • Response headers have lower-case names, and repeated headers are joined with , . A JSON response is in json, read with strict rules in the sandbox: whole numbers of 2^53 (9,007,199,254,740,992) or more come back as text so no digit is lost, and a body that is not valid JSON or is nested more than 64 levels deep comes back as text in body. Other text is in body. A binary response is in body as base64, with body_encoding: base64.
  • Only HTTPS and public addresses work. Private and internal addresses, such as 10.0.0.1 or localhost, are blocked. Redirects are not followed.
- id: fetch
name: Fetch overdue devices from the ERP
uses: platform/http.request@v1
connections:
api: internal-erp
with:
method: GET
url: /v1/devices
query:
status: overdue

platform/evaluate@v1 returns the value you give it. Use it for a named check that later steps use in if:, or to stop a run when something is wrong.

Field Type Required Rules What it is
expression any yes none The value to compute, usually one ${{ }} expression.
fail_if_false boolean no default false Fail the step when the value is false (false, 0, ‘’ or null).
message string no not empty; at most 500 characters Error message when fail_if_false fails the step.

Other fields are refused.

Field Type Required Rules What it is
result any yes none The computed value; if: treats false, 0, ‘’ and null as false.

Other fields are refused.

With fail_if_false: true, a false value (false, 0, '' or null) fails the step with your message, or with “The check was false.”

- id: check
name: Check whether any devices are overdue
uses: platform/evaluate@v1
with:
expression: ${{ steps.fetch.outputs.json.devices.length > 0 }}

platform/transform@v1 runs a small JavaScript function in the sandbox. script is the body of a function that gets input and returns the result. It has the same ctx as a run: script, see Host API for scripts.

Field Type Required Rules What it is
script string yes not empty JavaScript function body run in the sandbox; receives input and returns the result.
input any no none Data passed to the script.

Other fields are refused.

Field Type Required Rules What it is
result any yes none What the script returned.

Other fields are refused.

The returned value is in result. The script passes the same checks as any script when you save: no import, no eval, no passwords written into the code.

script can’t contain ${{ }} expressions. Data placed in the code could run as code, for example a webhook body that contains JavaScript. Pass data through input instead and read it in the script. A workflow with an expression in script can’t be saved (script_expression), and a step whose script has one fails without running.

with:
input: { name: "${{ trigger.body.name }}" }
script: return input.name.toUpperCase();
- id: totals
name: Add up the invoice totals
uses: platform/transform@v1
with:
input: ${{ steps.shape.outputs.result }}
script: |
return input.reduce((sum, row) => sum + row.total, 0);

platform/json.map@v1 picks and renames fields of one object, or of each object in a list. The result has the same shape as data.

Field Type Required Rules What it is
data any yes none The object or list of objects to map.
mapping object yes none Output field name to a JSONPath into each item, e.g. { id: "$.device.id" }.
defaults object no none Value per output field when its path finds nothing.

Other fields are refused.

Field Type Required Rules What it is
result any yes none The mapped object, or list of objects when data is a list.

Other fields are refused.

Rules:

  • A path is $.a.b, $.items[0].id, $['key with spaces'], a.b or a JSON pointer such as /a/0/b. There are no wildcards or filters: one path finds at most one value.
  • A dotted output name, such as customer.name, makes a nested object.
  • A path that finds nothing (or null) gives the value from defaults, or null.
  • Two outputs that overlap, such as a and a.b, are refused.
  • At most 500 output fields, 100 000 items and a 32 MB result.
- id: shape
name: Keep the fields the finance system needs
uses: platform/json.map@v1
with:
data: ${{ steps.fetch.outputs.items }}
mapping:
number: $.invoice_no
customer.name: $.customer.name
total: $.amount.total
defaults:
total: 0

platform/csv.build@v1 writes rows to a CSV file (text/csv; charset=utf-8).

Field Type Required Rules What it is
rows list of object yes none Rows to write; each row is an object.
filename string yes not empty; at most 200 characters File name of the artifact, e.g. overdue.csv.
columns list of string no none Columns in order. Defaults to the keys of the first row.
delimiter string no not empty; one character; default "," Field separator.
include_header boolean no default true Write a header row.
bom boolean no default false Start the file with a UTF-8 byte order mark (helps Excel detect UTF-8).
escape_formulas boolean no default true Prefix text cells that start with =, +, -, @, tab or carriage return (also after leading spaces, and the full-width signs such as =) with ' so spreadsheets do not run them as formulas.

Other fields are refused.

Field Type Required Rules What it is
artifact string yes none Name of the produced file artifact; later steps reference it as ${{ artifacts.<name> }}.
rows integer yes at least 0 Number of rows written.

Other fields are refused.

Rules:

  • Columns come in the order of columns, or else the keys of the first row.
  • The file follows RFC 4180: lines end with CRLF, and cells with the delimiter, a quote or a line break are quoted.
  • Text is written as it is. Numbers and true or false as in JSON. null is an empty cell. Lists and objects are compact JSON.
  • Spreadsheet formulas. With escape_formulas (on by default), a text cell or header that starts with =, +, -, @, a tab or a carriage return (also after leading spaces, and the full-width signs such as =) gets a leading '. Excel, LibreOffice and Google Sheets then show it as text instead of running it. JSON numbers are never changed, so -5 as a number stays a number.
  • bom: true starts the file with a UTF-8 byte order mark, which helps Excel read ä and ö correctly.
  • The delimiter is one character, not a quote or a line break.
  • At most 100 000 rows, 1000 columns and 50 MB.
- id: report
name: Build a CSV of the overdue devices
uses: platform/csv.build@v1
with:
rows: ${{ steps.fetch.outputs.json.devices }}
columns: [id, owner, due_date]
filename: overdue.csv
bom: true

platform/csv.parse@v1 reads CSV from text or from a file made earlier in the run.

Field Type Required Rules What it is
text string no none CSV text to parse.
artifact any no none A CSV artifact, e.g. ${{ artifacts.import.csv }}.
delimiter string no not empty; one character; default "," Field separator.
has_header boolean no default true The first row names the columns.

At least one of: text; artifact. Other fields are refused.

Field Type Required Rules What it is
rows list of any yes none Parsed rows (objects when has_header is true, lists otherwise).
count integer yes at least 0 Number of rows.

Other fields are refused.

Rules:

  • It reads CSV the same way as ctx.formats.csv.parse in a script. See Data formats for the rules and limits all data formats share.
  • UTF-8 only. A byte order mark at the start is dropped. A byte that is not valid UTF-8 fails the step with format_error.
  • With has_header (the default), each row is an object keyed by the header. Empty header names become column_1 and so on, and repeated names get _2, _3.
  • Every row must have as many fields as the first one. A shorter or longer row fails the step with format_error and its line number, so a missing field is never read as an empty one.
  • Without a header, each row is a list of texts.
  • At most 50 MB, 100 000 rows and 1000 fields per row.
  • A format_error is not retried, because the same file would fail the same way.
- id: read
name: Read the imported price list
uses: platform/csv.parse@v1
with:
artifact: ${{ artifacts.prices.csv }}
delimiter: ";"

platform/pdf.render@v1 renders an HTML template with data and lays it out as an A4 PDF file.

Field Type Required Rules What it is
template string yes not empty Name of the HTML template to render.
data object no none Values available to the template.
filename string yes not empty; at most 200 characters File name of the artifact, e.g. report.pdf.

Other fields are refused.

Field Type Required Rules What it is
artifact string yes none Name of the produced file artifact; later steps reference it as ${{ artifacts.<name> }}.
pages integer yes at least 1 Number of pages.

Other fields are refused.

template names a template stored in Settings > Templates. See Use email and PDF templates. A name that doesn’t exist fails the step with template_not_found.

What the layout supports:

  • Headings (h1 to h6), paragraphs, div, line breaks, horizontal rules, bold and italic, and nested bullet and numbered lists.
  • Tables with equal-width columns, borders and a shaded header row. A cell taller than a page is cut.
  • Page breaks where needed, and a page number at the bottom of each page. At most 500 pages.
  • Not supported: CSS, images, links, colours, fonts and column widths. script and style are removed with their content.
  • Text uses the standard PDF font Helvetica. Finnish, Swedish and other Western European text is fine. Characters outside Windows-1252, such as emoji, Cyrillic, Arabic or Chinese, print as ?.

platform/zip@v1 packs files made earlier in the run into one zip file.

Field Type Required Rules What it is
files list of any yes at least 1 item Artifacts to put in the archive.
filename string yes not empty; at most 200 characters File name of the archive, e.g. reports.zip.

Other fields are refused.

Field Type Required Rules What it is
artifact string yes none Name of the produced file artifact; later steps reference it as ${{ artifacts.<name> }}.
count integer yes at least 1 Number of files in the archive.

Other fields are refused.

Rules:

  • Only the last part of each file name is kept, so an archive can never write outside the folder it is unpacked into. \ counts as a folder separator too.
  • Control characters and : become _, leading dots are removed, and empty names become file.
  • Repeated names get (2), (3) before the extension.
  • At most 100 files and 100 MB of input.
- id: bundle
name: Pack the reports into one file
uses: platform/zip@v1
with:
files: ["${{ artifacts.overdue.csv }}", "${{ artifacts.summary.csv }}"]
filename: reports.zip

platform/unzip@v1 unpacks a zip file made or downloaded earlier in the run. Every file in the archive becomes a file of the run, which later steps use as ${{ artifacts.<name> }}.

Field Type Required Rules What it is
artifact any yes none The zip file artifact to unpack, e.g. ${{ artifacts['orders.zip'] }}.
prefix string no at most 50 characters; letters, digits, ., _ and - Text put before every unpacked file name, e.g. in-.

Other fields are refused.

Field Type Required Rules What it is
artifacts list of string yes none Names of the unpacked files, in archive order.
count integer yes at least 0 Number of files unpacked.
skipped integer yes at least 0 Number of folders in the archive. Folders are not unpacked.

Other fields are refused.

Rules:

  • A file’s name is made from its path in the archive. Folder names are joined with _, so 2026/q1/report.csv becomes 2026_q1_report.csv. . and .. parts are dropped, and any character other than a letter, a digit, ., _ or - becomes _. A file can never land outside the run.
  • If two files would get the same name, the step fails with invalid_input and nothing is unpacked. Use prefix to keep names apart from files the run already has.
  • The archive is read in a sealed sandbox, and strictly. These fail with format_error, and nothing is unpacked:
    • a file that is not a zip archive, or is damaged (a file whose checksum does not match, extra data after the end of the archive),
    • links, password protected or encrypted archives, archives split into parts, and ZIP64 archives (only needed for files of 4 GB or more),
    • compression other than “stored” and “deflate” (every common zip tool uses these),
    • two files whose names differ only in upper and lower case, such as Report.csv and report.csv.
  • At most 100 files (and 1,000 files and folders), and 100 MB unpacked in total. A file larger than 1 MB may unpack to at most 100 times its packed size. These limits are checked before anything is unpacked, and a larger archive fails with format_error, so a “zip bomb” can’t fill your storage.
  • A file keeps a type such as CSV, PDF or PNG only when its name and its first bytes agree. Everything else, web pages and SVG images included, is stored as a plain download.
  • Each unpacked file counts toward the run’s file limits and your plan’s storage_mb.
- id: unpack
name: Unpack the supplier's order export
uses: platform/unzip@v1
with:
artifact: "${{ artifacts['orders.zip'] }}"
prefix: in-
- id: read
name: Read the orders
uses: platform/csv.parse@v1
with:
artifact: "${{ artifacts['in-orders.csv'] }}"

platform/email.send@v1 sends an email from your company’s verified sender. See Set up email senders.

Field Type Required Rules What it is
to list of string yes at least 1 item; at most 50 items; each item: at least 3 characters Recipients.
cc list of string no at most 50 items; each item: at least 3 characters Copy recipients.
bcc list of string no at most 50 items; each item: at least 3 characters Blind copy recipients.
subject string yes not empty; at most 250 characters Subject line.
template string no none Name of the tenant email template, filled with data. Every value is escaped for where it goes in the HTML.
text string no none Plain-text body.
html string no none Literal HTML body. It cannot contain ${{ }} expressions: to put values in HTML, use template and data.
data object no none Values available to the template. They are escaped, never read as HTML or template syntax.
attachments list of any no at most 10 items Artifacts to attach, e.g. ${{ artifacts.overdue.csv }}.

At least one of: template; text; html. Other fields are refused.

Field Type Required Rules What it is
message_id string yes none Identifier of the sent message.
accepted list of string yes none Recipients the mail server accepted.

Other fields are refused.

Rules:

  • The body is html or text, or both. With only html, Liitora makes the plain-text part from it.
  • template names a template stored in Settings > Templates and fills it with data. See Use email and PDF templates. Use template or html, not both.
  • html is fixed HTML that you write. It can’t contain ${{ }} expressions, because data placed in it could add links, forms or hidden text to a message sent from your domain. A workflow with an expression in html can’t be saved (html_expression), and a step whose html has one fails with invalid_input and sends nothing. To put values in HTML, store the HTML as a template and give the values in data. subject and text are plain text and can use expressions.
  • Addresses are plain name@example.com. Display names (Anna <anna@example.com>) and IP addresses are refused. Repeated addresses are sent once.
  • At most 50 addresses per list and 100 in total. The subject is one line of at most 250 characters.
  • Text is at most 1 MB and HTML at most 2 MB. At most 10 attachments, 20 MB together.
  • accepted lists the addresses the mail server accepted.
  • If your company has email senders, the default one (or else the first verified one) sends. An unverified sender fails the step with email_sender_unverified. Without any sender of your own, Liitora’s own sender address is used.
- id: email
name: Email the report to IT
uses: platform/email.send@v1
with:
to: [it@example.com]
subject: "Overdue devices: ${{ steps.report.outputs.rows }}"
text: The list is attached.
attachments: ["${{ artifacts.overdue.csv }}"]

platform/webhook.send@v1 sends JSON with POST to an https:// address, or to a path on the connection bound to the target slot.

Field Type Required Rules What it is
url string yes not empty Absolute https URL, or a path when the target connection is set.
json any no none Body sent as JSON.
headers object no none Extra headers.
sign boolean no default false Add an HMAC signature header using the connection’s signing secret.

Other fields are refused.

Field Type Required Rules What it is
status integer yes none Response status.
body string no none Response body as text.

Other fields are refused.

Rules:

  • Every request has an Idempotency-Key header. It stays the same when the step is retried, so the receiver can drop a repeat.
  • With sign: true, Liitora adds X-Liitora-Timestamp (unix seconds) and X-Liitora-Signature: sha256=<hex>: the HMAC-SHA256 of <timestamp>.<body> with the signing secret of the target connection. The action never sees the secret. Give the connection a secret reference named signing_secret. Without it the step fails with signing_unavailable.
  • You can’t set Idempotency-Key or any X-Liitora- header yourself.
  • Any status other than 2xx fails the step. 429 and 5xx can be retried. The response body is kept up to 64 KB. The timeout is 30 seconds.

The receiver checks the signature the same way as for incoming webhooks, see Webhook signing.

- id: notify
name: Tell the IT channel about the new report
uses: platform/webhook.send@v1
connections:
target: it-chat
with:
url: /hooks/it
sign: true
json:
text: "The overdue devices report is ready."

platform/state.get@v1 reads a small value saved by an earlier run of the same workflow, such as the time of the last sync.

Field Type Required Rules What it is
key string yes not empty; at most 200 characters State key, e.g. last_synced_at.
default any no none Returned when the key is not set.

Other fields are refused.

Field Type Required Rules What it is
value any yes none The stored value, or default.
found boolean yes none Whether the key was set.

Other fields are refused.

- id: cursor
name: Read when the last sync ran
uses: platform/state.get@v1
with:
key: last_synced_at
default: "2026-01-01T00:00:00Z"

platform/state.set@v1 saves a small value for later runs of the same workflow.

Field Type Required Rules What it is
key string yes not empty; at most 200 characters State key.
value any yes none Value to store (small JSON).

Other fields are refused.

Field Type Required Rules What it is
previous any yes none The value before this step, or null.

Other fields are refused.

Rules:

  • A value is at most 64 KB of JSON. Values belong to one workflow in your company.
  • state.set reads the old value first and then writes. Two runs at the same time can both see the same previous. If the value must never be read twice, add concurrency: to the workflow.
  • Test cases can set the state before the run with given.state.
- id: remember
name: Remember when this sync ran
uses: platform/state.set@v1
with:
key: last_synced_at
value: ${{ trigger.now }}

platform/sync.diff@v1 compares records by key and tells you which ones were created, updated or deleted. Compare the source with a target list, such as what the receiving system has now. Or, for a source that cannot filter by “modified since”, set snapshot: true to compare it with what the same step saw in the last successful run.

Field Type Required Rules What it is
source list of objects yes at most 50,000 records and 20 MB The records as they are now.
target list of objects one of two at most 50,000 records and 20 MB The records to compare with, e.g. what the receiving system has. Leave out with snapshot: true.
snapshot boolean one of two default false Compare source with the source of the last successful run instead of a target.
key string or list of string yes 1 to 10 fields, no repeats; each name at most 200 characters The field that identifies a record, e.g. id, or a list of fields for a composite key.
compare list of string no at most 500 fields, no repeats Compare only these fields. Default: every field.
ignore list of string no at most 500 fields, no repeats Fields never compared, e.g. updated_at.
trim_strings boolean no default false Ignore spaces at the start and end of text values and keys.
allow_empty_source boolean no default false Accept an empty source that deletes every record. Default: an empty source fails the step.

Set target or snapshot: true, not both. Other fields are refused.

Field Type Required Rules What it is
mode string yes one of target, snapshot What the source was compared with.
counts object yes none source, target, created, updated, deleted and unchanged, each a number. In snapshot mode target counts the records in the snapshot used.
created list of objects yes none Source records whose key is new, in source order. Each has key (the key fields and their values) and record (the source record).
updated list of objects yes none Source records whose compared fields changed, in source order, with key and record. In target mode each also has changes: a list of {field, from, to}, where from is the target value and to the source value.
deleted list of objects yes none Keys no longer in the source, with key. In target mode each also has record, the target record.
snapshot object or null yes none Snapshot mode only, else null. previous is none (no snapshot yet), used or reset (made with other settings, so every record counts as created). save_on_success says whether this run saves a new snapshot when it succeeds.

Other fields are refused.

Unchanged records are only counted, not listed, so the output stays small.

Rules:

  • Keys. Key values are text, numbers or true/false. A key field that is missing or null fails the step, and so does a key that is an object or a list. Keys are strict about type: 1 and "1" are different records. A key is at most 1 KB, and two records on the same side with the same key fail the step.
  • Comparing values. Key fields never count as changes. Numbers compare by value, so 1.0 equals 1, but 1 and "1" differ. A missing field is the same as null. Nested objects and lists compare as a whole value: a change anywhere inside is one change of that field. Lists compare in order. Text compares exactly, so letters with accents written in two different ways in Unicode count as different.
  • Empty source. When the source is empty but the target or snapshot has records, the step fails with sync_source_empty. A source system that has an outage and returns nothing would otherwise look like “delete everything”. Set allow_empty_source: true when an empty source is real.
  • Snapshot mode.
    • The snapshot is saved only when the whole run succeeds. If a later step fails, the next run reports the same changes again, so none are lost.
    • Dry runs and test runs read the snapshot but never save it, and they show save_on_success: false. A test case starts without a snapshot unless it gives one in given.snapshots.
    • Each workflow step has its own snapshot, separately for test and production. If you change key, compare, ignore or trim_strings, the next run starts over (previous: reset).
    • Two runs at the same time: the first to succeed saves its snapshot, and the other one’s changes are reported again next time.
    • Archiving the workflow deletes its snapshots. You can see each snapshot’s record count and when it was saved on the workflow’s Overview tab, and reset one there so the next run starts over. See Reset what a sync step remembers.
    • Snapshot mode cannot be used inside for_each.
    • On your own runner, snapshot mode needs a runner group that sends everything back. See Change detection on a runner.
  • What a snapshot keeps. Liitora keeps the record keys and a fingerprint (a salted hash) of each record’s compared fields, never the field values. That is why snapshot mode lists no changes and gives deleted records by key only. The keys are stored as they are, so use an id rather than personal data such as an email address as the key where you can. A key that contains a secret value fails the step with sync_key_secret.
  • Errors. The step fails with one of these codes. The messages name the record’s position, such as source[3], and the field, never its value.
    • sync_record_invalid: a record is not an object.
    • sync_key_missing, sync_key_invalid, sync_key_too_long and sync_duplicate_key: see Keys above.
    • sync_too_many_records or sync_input_too_large: a side has over 50,000 records or 20 MB.
    • sync_snapshot_too_large: the snapshot would be over 16 MB.
    • sync_target_missing or sync_target_with_snapshot: set either target or snapshot: true.
    • sync_snapshot_in_for_each: snapshot mode was used inside for_each.
    • sync_snapshot_unavailable: the snapshot could not be read or saved. The step can be retried.
- id: diff
name: Find contacts that changed since the last sync
uses: platform/sync.diff@v1
with:
source: ${{ steps.fetch.outputs.json.contacts }}
snapshot: true
key: id
ignore: [updated_at]
trim_strings: true

Compare with what the receiving system has, with a composite key:

- id: diff
name: Compare prices with the shop
uses: platform/sync.diff@v1
with:
source: ${{ steps.erp.outputs.json.prices }}
target: ${{ steps.shop.outputs.json.prices }}
key: [sku, currency]
compare: [amount, valid_from]

platform/xml.parse@v1 reads XML into a value that later steps use like JSON. It reads XML the same way as ctx.formats.xml.parse in a script, see XML in scripts. Every option and limit is listed in Data formats.

Field Type Required Rules What it is
text string one of two at most 8 MB XML text to read.
artifact any one of two at most 8 MB An XML file, e.g. ${{ artifacts.import.xml }}.
charset string no one of utf-8, utf-16, windows-1252, iso-8859-1, iso-8859-15, us-ascii (and their usual aliases); default utf-8 The character set of the file. If the XML declaration names one, it must be the same.
arrays "always" or list of string no none Element paths such as order.items.item that are always lists, even with one element. "always" makes every child element a list.
namespaces string no one of strip, prefix, uri; default strip strip keeps local names, prefix keeps names as written, uri names namespaced elements {uri}name.

Set text or artifact, not both. Other fields are refused.

Field Type Required Rules What it is
value object yes none The document: an object with one key, the root element.

Other fields are refused.

How XML becomes a value:

  • An element with only text is a string: <name>Ada</name> becomes "name": "Ada". An empty element is "".
  • Attributes are keys that start with @, and the text next to attributes or child elements is #text: <price currency="EUR">9.50</price> becomes "price": {"@currency": "EUR", "#text": "9.50"}.
  • An element that appears more than once is a list. Use arrays for elements that can appear once or many times, so the shape does not change with the data.
  • All values are text. Convert numbers and dates in a later step.
  • Comments and processing instructions are left out. CDATA is text.

For safety, a document with a DOCTYPE is refused, and so are entities other than the five XML ones (&amp;, &lt;, &gt;, &quot;, &apos;). Documents are limited to 8 MB, 64 levels of nesting and 200,000 elements, attributes and text pieces. A file that is not valid in its character set fails the step. These failures are format_error, and the step is not retried, because the same XML fails the same way again.

- id: order
name: Read the order
uses: platform/xml.parse@v1
with:
artifact: ${{ artifacts.order.xml }}
arrays: [order.lines.line]

platform/xml.build@v1 writes a value as XML, for a system that takes XML. The value has the same shape xml.parse gives.

Field Type Required Rules What it is
value object yes one key, the root element The document to write.
declaration boolean no default true Start with <?xml version="1.0" encoding="UTF-8"?>.
filename string no 1 to 255 characters, a file name without folders Write the XML to a file with this name instead of returning it as text.

Other fields are refused.

Field Type Required Rules What it is
xml string no none The XML text, when filename is not set.
artifact string no none The name of the file, when filename is set. Later steps use it as ${{ artifacts.<name> }}.
bytes integer yes at least 0 The size of the XML in bytes.

Other fields are refused.

Rules:

  • Text is escaped for you, so <, & and quotes in values are safe.
  • Numbers and true/false are written as text. A null value writes an empty element.
  • Element and attribute names must be valid XML names, and a prefix such as p: needs its @xmlns:p declaration.
  • Workflow values do not keep the order of keys in an object. When the order of child elements matters, list them in #content, which is a list: {"order": {"#content": [{"id": "1"}, {"date": "2026-10-04"}]}}.
  • The XML is at most 16 MB.
- id: invoice
name: Write the invoice XML
uses: platform/xml.build@v1
with:
value:
invoice:
"@version": "2"
number: ${{ steps.fetch.outputs.json.number }}
total: ${{ steps.fetch.outputs.json.total }}
filename: invoice.xml

platform/xml.split@v1 reads the repeating records of an XML document, such as every <order> in <orders>, as a list. Use it with for_each to handle each record in its own step.

Field Type Required Rules What it is
path string yes 1 to 1024 characters Element names from the root to the record, joined by dots, e.g. orders.order.
text string one of two at most 8 MB XML text to read.
artifact any one of two at most 8 MB An XML file, e.g. ${{ artifacts.import.xml }}.
charset string no one of utf-8, utf-16, windows-1252, iso-8859-1, iso-8859-15, us-ascii (and their usual aliases); default utf-8 The character set of the file. If the XML declaration names one, it must be the same.
arrays "always" or list of string no none Element paths such as order.items.item that are always lists, even with one element. "always" makes every child element a list.
namespaces string no one of strip, prefix, uri; default strip strip keeps local names, prefix keeps names as written, uri names namespaced elements {uri}name.

Set text or artifact, not both. Other fields are refused.

Field Type Required Rules What it is
records list yes none The elements at path, in document order, each in the shape xml.parse gives it.
count integer yes at least 0 The number of records.

Other fields are refused.

Rules:

  • One record is still a list with one item.
  • When the element before the last one exists but has no records, the list is empty. When any other part of the path is missing, the step fails, so a typing mistake in path does not look like “no records”.
  • When an element on the path repeats, the records of each one are listed in order: r.group.item lists the items of every group.
  • At most 100,000 records. The limits of xml.parse apply too.
- id: orders
name: Split the export into orders
uses: platform/xml.split@v1
with:
artifact: ${{ artifacts.export.xml }}
path: orders.order
arrays: [orders.order.line]

platform/xlsx.read@v1 reads the rows of one sheet of an Excel workbook (.xlsx) made earlier in the run, for example one downloaded with files.download or received by email.

Field Type Required Rules What it is
artifact any yes at most 16 MB An .xlsx file, e.g. ${{ artifacts.report.xlsx }}.
sheet string or integer no at least 0; default the first sheet The sheet to read: its name (an exact match first, then ignoring case) or its number, counting from 0.
has_header boolean no default true Row 1 names the columns and every later row becomes an object. Otherwise every row is a list.
dates string no one of iso, serial; default "iso" Cells with a date or time format become ISO 8601 text (iso) or stay Excel serial numbers (serial).

Other fields are refused.

Field Type Required Rules What it is
rows list yes none The rows (objects when has_header is true, lists otherwise). Empty cells are null.
count integer yes at least 0 Number of rows.
sheet string yes none Name of the sheet that was read.
sheets list of string yes none Names of all sheets in the workbook, in order.
epoch integer yes one of 1900, 1904 The workbook’s date system.
formulas integer yes at least 0 How many formula cells were read. Their values are the ones Excel saved, never recalculated.

Other fields are refused.

Rules:

  • It reads workbooks the same way as ctx.formats.xlsx.read in a script. See Excel workbooks for the rules all workbooks follow.
  • Only .xlsx. Macro workbooks (.xlsm), binary workbooks (.xlsb) and old .xls files fail the step with format_error, so a file that could carry code is never opened.
  • Formulas are never run. A formula cell gives the value Excel saved with it. formulas tells you how many there were, so you can spot a file whose values may be out of date.
  • Numbers come back as numbers, true and false as booleans, and text as text. An error cell such as #N/A comes back as its text.
  • Date cells become ISO 8601 text: 2026-10-04 for a date, 2026-10-04T08:30:00 for a date and time, 08:30:00 for a time. Both the 1900 and the 1904 date systems are read correctly.
  • With has_header (the default), empty header names become column_1 and so on, and repeated names get _2, _3.
  • The rows start at cell A1, so empty leading rows and columns are kept as empty cells.
  • At most 16 MB, 100,000 rows and 1,000 columns. Zip limits also apply: see xlsx.read limits. A workbook that unpacks to far more than its size, or holds another archive, fails the step.
  • A format_error is not retried, because the same file would fail the same way.
- id: read
name: Read the price list
uses: platform/xlsx.read@v1
with:
artifact: ${{ artifacts.prices.xlsx }}
sheet: Prices

platform/xlsx.write@v1 writes rows to an Excel workbook (.xlsx) with one sheet.

Field Type Required Rules What it is
rows list of object yes none Rows to write; each row is an object.
filename string yes not empty; at most 200 characters File name of the artifact, e.g. report.xlsx.
columns list of string no none Columns in order. Defaults to the keys of the first row.
sheet string no 1 to 31 characters; default "Sheet1" Name of the sheet.
include_header boolean no default true Write a header row.

Other fields are refused.

Field Type Required Rules What it is
artifact string yes none Name of the produced file artifact; later steps reference it as ${{ artifacts.<name> }}.
rows integer yes at least 0 Number of rows written, not counting the header.

Other fields are refused.

Rules:

  • Columns come in the order of columns, or else the keys of the first row. A missing value is an empty cell, and keys not in columns are left out.
  • Numbers are written as numbers, true and false as booleans, and null as an empty cell. Lists and objects are written as compact JSON text.
  • Spreadsheet formulas. A text cell or header that starts with =, +, -, @, a tab or a carriage return (also after leading spaces, and the full-width signs such as =) always gets a leading ', so Excel shows it as text instead of running it. Numbers are never changed.
  • A sheet name can’t contain \ / ? * [ ] : and can’t start or end with '.
  • A cell holds at most 32,767 characters. A longer value fails the step with format_error instead of being cut.
  • At most 100,000 rows and 1,000 columns.
- id: report
name: Build the monthly report
uses: platform/xlsx.write@v1
with:
rows: ${{ steps.fetch.outputs.json.devices }}
columns: [id, owner, due_date]
sheet: Overdue
filename: overdue.xlsx

platform/fixed.parse@v1 reads fixed-width records, where every field has a set position and width on each line, as many bank, payroll and older systems use.

Field Type Required Rules What it is
fields list of objects yes at least one; see below The layout. Overlapping fields are refused.
unit string yes one of chars, bytes What start and width count: characters of the text, or bytes in encoding.
text string one of two at most 16 MB Fixed-width text to parse.
artifact any one of two at most 16 MB A fixed-width file, e.g. ${{ artifacts.payments.txt }}.
encoding string no one of utf-8, utf-16, windows-1252, iso-8859-1, iso-8859-15, us-ascii (and their usual aliases); default utf-8 The character set of the file, and of the bytes that unit: bytes counts. utf-16 works with unit: chars only.
trim string no one of both, left, right, none; default "both" Which padding spaces to remove from every field.
short_lines string no one of error, pad; default "error" A line shorter than the record fails the step, or is read as if padded with spaces.
long_lines string no one of error, ignore; default "error" A line longer than the record fails the step, or its extra characters are dropped.

Each field has a name, a width of at least 1 and an optional start, counting from 1, which defaults to right after the previous field. A field can set its own trim. Set text or artifact, not both. Other fields are refused.

Field Type Required Rules What it is
rows list of object yes none One object per record, with each field’s value as text.
count integer yes at least 0 Number of records.

Other fields are refused.

Rules:

  • It reads records the same way as ctx.formats.fixed.parse in a script. See Fixed-width records.
  • Every line is one record. Lines end with LF or CRLF, and empty lines are skipped.
  • Use unit: bytes when the file’s specification counts bytes and the file has letters such as ä or ö that take more than one byte in its encoding. A field that would split a character fails the step.
  • The layout is checked before any data is read: overlapping fields, repeated names and fields past 65,536 characters fail the step with invalid_input.
  • Gaps between fields are allowed and are not read.
  • Values are always text. Use json.map or evaluate to turn them into numbers or dates.
  • At most 16 MB, 100,000 records and 1,000 fields.
- id: payments
name: Read the payment file
uses: platform/fixed.parse@v1
with:
artifact: ${{ artifacts.payments.txt }}
unit: bytes
encoding: iso-8859-1
fields:
- { name: account, width: 18 }
- { name: amount, width: 12 }
- { name: reference, start: 32, width: 20 }

platform/fixed.build@v1 writes rows as fixed-width records, as text or as a file.

Field Type Required Rules What it is
rows list of object yes none Rows to write; each row is an object. A missing or null value is all padding.
fields list of objects yes at least one; see below The layout. Overlapping fields are refused; gaps are filled with spaces.
unit string yes one of chars, bytes What start and width count: characters, or bytes in encoding.
encoding string no one of utf-8, windows-1252, iso-8859-1, iso-8859-15, us-ascii (and their usual aliases); unit: bytes only The character set of the file. Anything other than UTF-8 needs filename.
line_ending string no one of "\n", "\r\n"; default "\n" Written after every record.
filename string no not empty; at most 200 characters Write the records to a file with this name instead of returning them as text.

Each field has a name, a width of at least 1, an optional start (counting from 1, defaults to right after the previous field), an optional align (left, the default, or right) and an optional pad, one character (a space by default). Other fields are refused.

Field Type Required Rules What it is
text string no none The records, when filename is not set.
artifact string no none Name of the produced file artifact, when filename is set; later steps reference it as ${{ artifacts.<name> }}.
rows integer yes at least 0 Number of records written.

Other fields are refused.

Rules:

  • Text is written as it is. Numbers and true or false as in JSON. Lists and objects fail the step.
  • Nothing is cut. A value longer than its field fails the step with format_error, and so does a character the encoding can’t write. Make values fit first, for example with json.map.
  • Right-aligned numbers with pad: "0" give zero-filled amounts such as 000125.
  • A file has the content type text/plain with the encoding as its charset.
  • At most 100,000 records and 16 MB.
- id: export
name: Write the payroll file
uses: platform/fixed.build@v1
with:
rows: ${{ steps.fetch.outputs.json.payments }}
unit: bytes
encoding: iso-8859-1
line_ending: "\r\n"
fields:
- { name: employee, width: 10 }
- { name: amount, width: 12, align: right, pad: "0" }
filename: payroll.txt

platform/wait@v1 pauses the run for a few seconds, for example to give another system time to finish.

Field Type Required Rules What it is
seconds integer yes at least 1; at most 30 How long to wait. Short delays only in the MVP.

Other fields are refused.

Field Type Required Rules What it is
waited_seconds integer yes at least 0 How long the step waited.

Other fields are refused.

Longer waits are not available yet.

- id: pause
name: Give the ERP time to finish the export
uses: platform/wait@v1
with:
seconds: 10

platform/ai.generate@v1 asks your own AI model to classify, summarise or extract, and checks the answer against a JSON Schema. The model slot takes an AI connection, see Connect an AI model.

Field Type Required Rules What it is
prompt string yes 1 to 32768 characters What the model should do, for example “Classify this support ticket”. Sent as the system instruction.
prompt_version string no at most 100 characters Your own label for this prompt, for example ticket-triage-3. It is logged with every call.
input any no none The data the model works on, sent as JSON. Send only the fields the task needs.
output_schema object yes type: object at the root; at most 64 KB JSON Schema the answer must match.
model string yes 1 to 200 characters The model to use, for example gpt-4o-mini or mistral-small-latest. On Azure OpenAI this is your deployment name.
max_output_tokens integer no 1 to 32768; default 1024 Most tokens the answer may use.
timeout_seconds integer no 1 to 300; default 120 How long one call may take.

Other fields are refused.

Field Type Required Rules What it is
output any yes none The answer, checked against output_schema. Null only when the call was not sent (a dry run).
provider string yes openai, azure_openai, anthropic, mistral or openai_compatible The AI provider that answered.
model string yes none The model that was asked.
prompt_version string no none The prompt_version you gave.
stop_reason string no none Why the model stopped, as the provider reported it.
request_id string no none The provider’s ID for the call, for support requests.
usage object yes input_tokens, output_tokens, total_tokens Tokens the call used.
not_sent boolean no none True when the call was not sent, because this is a dry run.

Other fields are refused.

Rules:

  • The step fails when the answer is refused (ai_refused), cut off at max_output_tokens (ai_truncated), removed by a content filter (ai_content_filter), or isn’t JSON that matches output_schema (ai_invalid_output). The answer is then not used. See AI errors.
  • A busy provider or a rate limit is tried again, up to 3 calls in all, waiting as long as the provider asks (at most 60 seconds).
  • The provider’s reply and the answer are read with strict JSON rules in the sandbox, like a JSON answer of http.request. A whole number of 2^53 (9,007,199,254,740,992) or more in the answer comes back as text, so it doesn’t match type: integer; ask for such IDs as strings. The reading counts toward compute units.
  • Each call counts against the run’s AI token limit (ai_tokens_per_run, see Limits per plan).
  • The request with the prompt, the input and the schema is at most 1 MB.
  • Liitora never sends who the end user is, and the HTTP log keeps no request or response bodies of AI calls.
- id: triage
name: Label the support ticket
uses: platform/ai.generate@v1
connections:
model: company-ai
with:
model: mistral-small-latest
prompt: Label the ticket as bug, question or billing.
input:
subject: ${{ trigger.body.subject }}
output_schema:
type: object
properties:
label: { type: string, enum: [bug, question, billing] }
required: [label]
additionalProperties: false

The files.* actions read and write files on an SFTP server or an S3 bucket. Each one needs the store connection slot, filled with a file connection. Every path is relative to the connection’s base folder (SFTP) or prefix (S3). . and .. are not allowed, also not written with percent signs (such as %2e%2e), so a path can never leave the base. Other percent signs are part of the name: a%2Fb is one file called a%2Fb. On SFTP, a path that goes through a symbolic link is refused, so use the folder’s real path. In a dry run, files.upload, files.move and files.delete change nothing and only report what they would have done.

platform/files.list@v1 lists the regular files in a folder, sorted by path.

Field Type Required Rules What it is
path string no at most 1024 characters; default empty The folder. Empty lists the base itself.
pattern string no 1 to 200 characters Only names that match, with *, ? and [abc], for example *.csv.
modified_since string no RFC 3339 time Only files modified at or after this time.
limit integer no 1 to 1000; default 100 At most this many files.

Other fields are refused.

Field Type Required Rules What it is
files array yes Each file with path, name, size and modified.
count integer yes at least 0 How many files were listed.
- id: list
uses: platform/files.list@v1
connections:
store: partner-sftp
with:
path: outbox
pattern: "*.csv"

platform/files.download@v1 copies a file into an artifact that later steps can read.

Field Type Required Rules What it is
path string yes 1 to 1024 characters The file, for example ${{ trigger.file.path }}.
artifact_name string no 1 to 200 characters Name of the artifact. The default is the file name.
content_type string no 1 to 200 characters Media type of the artifact. The default is guessed from the file name.

Other fields are refused.

Field Type Required Rules What it is
artifact object yes The stored artifact with id, name, content_type and size_bytes.
file object yes The file with path, name, size and modified.
- id: download
uses: platform/files.download@v1
connections:
store: partner-sftp
with:
path: ${{ trigger.file.path }}

platform/files.upload@v1 writes an artifact, text or JSON to a file.

Field Type Required Rules What it is
path string yes 1 to 1024 characters The target file. On SFTP its folder must exist.
artifact artifact one of three An artifact to upload.
text string one of three Text to upload (UTF-8).
json any one of three A value to upload as JSON.
overwrite string no fail, replace or skip; default fail What to do when the target exists.

Set exactly one of artifact, text and json. Other fields are refused.

Field Type Required Rules What it is
path string yes The target path.
size integer yes at least 0 Bytes written, or that would have been written.
written boolean yes False when the target existed and overwrite was skip, or in a dry run.
- id: upload
uses: platform/files.upload@v1
connections:
store: partner-sftp
with:
path: inbox/report.csv
artifact: ${{ artifacts.report.csv }}
overwrite: replace

platform/files.move@v1 moves or renames a file, for example into an archive folder.

Field Type Required Rules What it is
from string yes 1 to 1024 characters The file to move.
to string yes 1 to 1024 characters The new path. A path that ends with / keeps the file name.
overwrite string no fail, replace or skip; default fail What to do when the target exists.

Other fields are refused.

Field Type Required Rules What it is
from string yes The old path.
to string yes The resolved new path.
moved boolean yes False when the target existed and overwrite was skip, or in a dry run.
- id: archive
uses: platform/files.move@v1
connections:
store: partner-sftp
with:
from: ${{ trigger.file.path }}
to: archive/

platform/files.delete@v1 deletes a file.

Field Type Required Rules What it is
path string yes 1 to 1024 characters The file to delete.
missing_ok boolean no default false Succeed when the file does not exist. By default the step fails.

Other fields are refused.

Field Type Required Rules What it is
path string yes The path.
deleted boolean yes False when the file did not exist, or in a dry run.
- id: cleanup
uses: platform/files.delete@v1
connections:
store: partner-sftp
with:
path: outbox/old.csv
missing_ok: true

email.send (with template) and pdf.render use one small template language without logic. You store templates in Settings > Templates, or your AI assistant stores them with set_template. See Use email and PDF templates.

  • {{ path }} inserts a value, such as {{ customer.name }}, {{ rows.0.id }}, {{ this }} or {{ @index }}. In HTML every value is escaped, and there is no raw form, so data can never add markup.
  • A value is escaped for the place it goes: in text, in a quoted attribute such as title="{{ name }}", or in an attribute without quotes. Text in data that looks like {{ }} or ${{ }} stays plain text.
  • In a link or address attribute such as href or src, a value can only start a relative address or an http, https, mailto or tel address. Any other start, such as javascript: or data:, is replaced by about:invalid#blocked, so the link does nothing.
  • In a link or address attribute, write the start of the address yourself, such as href="https://{{ host }}/orders". A template can’t be saved when the data could still choose the kind of address: a value followed by a : (also written as &#58; or &colon;), such as href="{{ scheme }}://example.com". Character references such as &#106; count as the character they stand for, so they can’t hide a javascript: start either. Before a value, end a character reference with ;.
  • Some places can’t take a value at all, because no escaping makes data safe there: inside <script> or <style>, in style, srcset, srcdoc and event attributes such as onclick, and inside a tag outside an attribute value, such as <p {{ attrs }}>. A template with a value in one of these places can’t be saved, and the error names the line. To choose between fixed styles, use a block: <p {{#if late}}style="color: red"{{/if}}>.
  • {{#each list}} ... {{/each}} repeats for each item. Inside it, {{ this }} is the item and {{ this.name }} is a field of the item. A name without this., such as {{ name }}, always means the top-level data, also inside a loop. So a field in your data can never take the place of a top-level value with the same name. In a loop inside a loop, this is the item of the inner loop, and {{#each this.lines}} repeats a list inside the item. An empty or missing list shows nothing.
  • {{#if value}} ... {{else}} ... {{/if}} uses the same rules as if:. An empty list counts as false.
  • Errors name the line, for example “line 3: {{#if}} is never closed”.
  • Limits: 512 KB of template, 16 nested blocks, 200 000 loop rounds and 8 MB of output.

A built-in action fails the step with a message that is safe to show. It never contains your data or secret values. Wrong input fails at once and is not retried. Failures from the other system, such as a 429 or 5xx status or a timeout, can be retried with retry:. The step’s error code is shown in the run log. See Error codes.