Manual · 11

Workflows

Workflows connect small steps into a process – similar to Node-RED, only slimmed down. A flow begins at a start node (button, schedule, HTTP endpoint or Collection event); after that you fetch data, transform it with JavaScript, branch, store it in a Collection or return it as an HTTP response. Typical cases: a JSON endpoint for a display board, a contact form that ends up in a Collection, a nightly sync with another system.

The editor

Under Workflows, every Workflow appears as a tab at the top. Below:

  • Palette (left): drag Nodes onto the canvas or click them.
  • Canvas (middle): connect by dragging from the output (on the right of the node) to the input (on the left). An output may lead to several Nodes – the message then runs into all branches. Del deletes selected Nodes and wires; the mouse wheel zooms, dragging on an empty area pans.
  • Sidebar (right): Node shows the settings of the clicked node, Debug the output of the run, Runs the last 50 runs, Storage the Workflow’s storage.
  • Copy and paste: You select several Nodes with Ctrl (Mac: ⌘) + click or with ⇧ + dragging a frame. Ctrl+C copies them along with the wires between them, Ctrl+V pastes them – also into another Workflow or another browser tab; Ctrl+X cuts. The copies get new IDs and are selected afterwards so that you can move them right away.

▶ on the start node triggers the flow – with the state on the canvas, even unsaved. Save arms HTTP inputs, schedules and events; the Active switch turns them all off together (▶ still works).

After a run, each node shows how often it was executed (red on error). If you click it, you see its input at the bottom of the sidebar and what came out of each output – this way you quickly find the step that’s stuck.

Messages

A message msg travels over the wires – a JSON object. The actual data is in msg.payload; Nodes can attach further fields (msg.req for the HTTP input, msg.statusCode after an HTTP request). Each node gets its own copy, so branches don’t affect each other.

In the text fields of the Nodes (URL, header, body, entry ID), {{ msg.pfad }} inserts values, e.g. https://api.example/artikel/{{ msg.payload.id }}. Environment variables of the server are not available in Workflows – enter credentials and settings directly in the Node or in the script.

The Nodes

Node What it does
Start Starts via ▶ and/or on a schedule. msg.payload = the stored value (JSON or text)
HTTP in Your own endpoint under /api/v1/flows/<pfad>. msg.payload = JSON or form body, without a body the query parameters; msg.req = method, path, params, query, headers, body
Collection event Starts when an entry in a Collection is created, changed or deleted. msg.payload = the entry, msg.event, msg.collection
Function JavaScript with any number of outputs (see below)
Switch Checks a value (msg.payload.preis, msg.req.query.format, …) against rules – one output per rule. Rules: ==, !=, <, >, contains, matches RegExp, is true/false/empty, otherwise. Normally the message goes to the first matching rule, optionally to all
HTTP request Calls a URL (method, headers, body). The response ends up in msg.payload (JSON is parsed), the status in msg.statusCode, headers in msg.headers. Paths like /api/v1/… go to this CMS. Optional: error on status 400 and above
Collection Read entries (all or one), create, change, delete. For creating and changing, the fields come from msg.payload, the ID by default from {{ msg.payload.id }}. Result in msg.payload
IllusionDB Read records from IllusionDB (filter, sorting, grouping, pages), read one by primary key or column, create, change, delete, query schema and tables. Result in msg.payload
Delay Waits the configured time (seconds, fractions too) before the message moves on. Other branches keep running in the meantime. At most up to the run’s time limit
Join Collects messages from several branches and passes on one as soon as all have arrived – by default as many as there are wires leading in. msg.payload becomes the list of payloads (order of arrival) or an object with msg.topic as the key. If not everything arrives, nothing moves on (warning in Debug)
Email Sends an email via the instance’s SMTP access: To (several separated by commas), subject, text (empty = msg.payload), Reply to, optionally as HTML – everywhere with {{ msg.… }}. The message moves on unchanged
HTTP response Responds to the HTTP input: msg.payload as the body (object → JSON, text → text/plain), status and headers configurable
Debug Shows msg.payload, the whole msg or a path in the debug panel
Custom Your custom Nodes – reusable scripts with parameters and named outputs

Function with several outputs

The function’s code receives msg and decides with return where it goes next:

// Setting: 3 outputs
if (msg.payload.preis > 5) return [msg, null, null]   // output 1 only
if (msg.payload.preis > 2) return [null, msg, null]   // output 2 only
return [null, null, msg]                               // output 3 only
  • return msg – continue via output 1
  • return null – this branch ends here
  • return [a, null, b] – one message or null per output
  • return [[a, b, c]] – several messages via the same output (e.g. one per entry)

This way you map a lot of logic in one node instead of chaining many switches. The following are also available in the code:

Name Meaning
node.warn(…), console.log(…) Output to the debug panel
store.get(key, standard), store.set(key, wert), store.delete(key), store.keys() Workflow storage – persists across runs
fetch(url, { method, headers, body }) HTTP request, synchronous (no await): response with status, ok, headers, text(), json()
ic.items(slug), ic.item(slug, id) Entries of a Collection (including drafts – field published)
ic.createItem(slug, data, { published }) Create an entry; values are validated against the schema as in the API
ic.updateItem(slug, id, data), ic.deleteItem(slug, id) Change (only the fields passed) or delete
ic.data(slug, params) Like data() in the template – Collections and REST sources
ic.api(method, path, body) Any endpoint of the REST API
idb.records(…), idb.find(…), idb.create(…) … Read and write records in IllusionDB

The JavaScript engine (goja) knows ES5.1 and the common additions since then – const/let, arrow functions, template strings, destructuring, Array methods –, but no Intl and no await. You format numbers and dates by hand, for example betrag.toFixed(2).replace('.', ',') or with a regular expression for thousands separators.

If an ic.* or idb.* call fails (e.g. a required field is missing), it throws an error that you can catch with try { … } catch (e) { … }. fetch() only throws on network errors – you check the status via ok.

Examples

Drinks menu for a bar board – HTTP in (GET, path karte, public) → Collection (read, all, published only) → Function → HTTP response:

msg.payload = msg.payload.map((d) => ({ name: d.data.name, preis: d.data.preis.toFixed(2) + ' €' }))
return msg

https://deine-domain.de/api/v1/flows/karte then returns JSON, also via fetch() from another domain. With a Switch on msg.req.query.format you can, for example, answer ?format=text with a second branch returning plain text.

Contact form – HTTP in (POST, path kontakt, public) → Function with 2 outputs → output 1: Collection (create in “anfragen”, draft) → Function “Redirect” → HTTP response; output 2 directly to “Redirect”:

<form method="post" action="/api/v1/flows/kontakt">
  <input name="name" required> <textarea name="nachricht" required></textarea>
  <input name="website" style="display:none" tabindex="-1" autocomplete="off"> <!-- trap for bots -->
  <button>Senden</button>
</form>
// Check: bots fill in the hidden field
if (msg.payload.website) return [null, msg]
msg.payload = { name: msg.payload.name, nachricht: msg.payload.nachricht }
return [msg, null]
// "Redirect" (before the HTTP response; set status 303 there)
msg.payload = ''
return msg

In the HTTP response, also set the header Location: /danke. For the Collection “anfragen”, check Live data in the schema – otherwise the next bundle from local overwrites the requests that have come in.

Nightly sync – Start (schedule 0 3 * * *) → HTTP request (https://kasse.example/api/artikel, header Authorization: Bearer k123, error from 400) → Function:

const vorhanden = ic.items('drinks')
let neu = 0, geaendert = 0
for (const a of msg.payload) {
  const d = vorhanden.find((x) => x.data.name === a.name)
  if (!d) { ic.createItem('drinks', { name: a.name, preis: a.preis }); neu++ }
  else if (d.data.preis !== a.preis) { ic.updateItem('drinks', d.id, { preis: a.preis }); geaendert++ }
}
node.warn(neu + ' neu, ' + geaendert + ' geändert')
return null

Calling from a template

A flow with an HTTP in node (GET or ANY) can be called directly from a page or a Snippet – internally, without network and without a token (protected inputs too):

<ul>
{% for d in workflow("karte", "format", "kurz") %}
  <li>{{ d.name }} – {{ d.preis }}</li>
{% endfor %}
</ul>
  • The first value is the path of the HTTP input (karte for /api/v1/flows/karte; placeholders such as text/{wer} work too: workflow("text/Ada")).
  • Further values arrive in pairs as parameters in msg.req.query and msg.payload – here { "format": "kurz" }.
  • What comes back is what the HTTP response node would send: JSON as data (lists, objects), otherwise text. Without an HTTP response there is nothing.
  • Errors in the flow appear like other template errors. Time limit here: 10 seconds – slow external services make the page slow.
  • The page cache keeps pages with workflow() for at most one minute, so that external data stays fresh. In the editor’s preview, the result is remembered for 30 seconds so that the flow doesn’t run on every keystroke.
  • In the Workflow’s log, these runs appear with the trigger Page.

Several parameters, e.g. from the URL: Simply append further pairs – workflow("pfad", "a", wert1, "b", wert2, …). The parameters of the requested page are available in the template in route.query. Example: a drinks menu with search and maximum price at /karte?suche=mate&max=4.

Page /karte:

<form>
  <input name="suche" value="{{ route.query.suche }}" placeholder="Name">
  <input name="max" type="number" step="0.5" value="{{ route.query.max }}" placeholder="max. Preis">
  <button>Filtern</button>
</form>
<ul>
{% for d in workflow("karte-filter", "suche", route.query.suche, "max", route.query.max) %}
  <li>{{ d.name }} – {{ d.preis }} €</li>
{% empty %}
  <li>Nichts gefunden.</li>
{% endfor %}
</ul>

Workflow: HTTP in (GET, path karte-filter; public is not needed for calling from the template) → Collection (read, all, published only) → Function → HTTP response. The Collection node overwrites msg.payload with the entries – that’s why the function reads the parameters from msg.req.query:

// Values from the URL are text (or missing) – check before use
const suche = String(msg.req.query.suche || '').trim().toLowerCase()
const max = parseFloat(String(msg.req.query.max || '').replace(',', '.'))
msg.payload = msg.payload
  .filter((d) => !suche || d.data.name.toLowerCase().includes(suche))
  .filter((d) => isNaN(max) || d.data.preis <= max)
  .map((d) => ({ name: d.data.name, preis: d.data.preis }))
return msg
  • If a parameter is missing from the URL, it arrives empty – here the menu then shows everything.
  • Values from the URL are visitor input: check them in the Workflow before they travel on, e.g. into an HTTP request to another service. In the template, output is escaped automatically.
  • The page cache distinguishes by the full address including parameters – each combination runs through the Workflow at most once per minute.
  • For parameters that occur several times (?a=1&a=2), route.query contains only the first value.

Schedule

The schedule on the start node is a cron expression with five fields – minute hour day month weekday (0 = Sunday). There are presets; at the top next to Active you see the next run.

Expression Meaning
*/15 * * * * every 15 minutes
0 * * * * hourly, on the hour
0 3 * * * daily at 3 am
30 7 * * 1-5 weekdays at 7:30 am
0 6 1 * * on the first of every month at 6 am

The server’s time zone applies (in the Docker image Europe/Berlin); a different one via prefix: CRON_TZ=Europe/Berlin 0 3 * * *. If a start node is still running at the next scheduled time, that time is skipped; scheduled times when the server was off are not made up.

HTTP inputs

  • Path after /api/v1/flows/, with placeholders: drinks/{id} matches /api/v1/flows/drinks/5 (msg.req.params.id is "5"), dateien/{rest...} at the end takes up the rest. A path with the same method belongs to exactly one active Workflow.
  • Protected (default): only with an API token (Authorization: Bearer … or, for GET, ?token=…) or as a signed-in admin. Public: without signing in, also via JavaScript from other domains (CORS).
  • The response comes from the first HTTP response node reached. If the flow ends without one, the server responds with 204; on an error with 500 and the message.
  • From pages and Snippets you call an HTTP input with workflow().
  • In the editor you test an HTTP input via Test run in its settings – with a payload of your choice, without a real request.

Collection events

Events run in the background after saving – saving in the admin or via the API doesn’t wait for them. They apply to every change via admin, API and Workflows. If a Workflow itself changes entries that trigger events again, the chain stops after three levels.

Storage and email

Storage: Every Workflow has a small key-value store that persists across runs – for counters, “last processed” or an intermediate result. Values are arbitrary JSON values (up to 64 KB per value, up to 1,000 keys). The Storage tab in the sidebar shows the content; there you delete individual keys or everything. The storage belongs to the instance: it doesn’t travel with bundles, so the state on the live server is kept when uploading. When the Workflow is deleted, its storage is deleted too.

Example – a schedule reports only requests that have come in since the last run (Start with schedule → Collection “read (all)” → Function → Email):

const zuletzt = store.get("zuletzt", 0)
const neu = msg.payload.filter((e) => e.id > zuletzt)
if (!neu.length) return null
store.set("zuletzt", Math.max(...neu.map((e) => e.id)))
msg.payload = neu.length + " neue Anfrage(n): " + neu.map((e) => e.data.name).join(", ")
return msg

Sending email: You enter the SMTP access under Settings → System → Email sending (server, port, user, password, sender such as Website <web@firma.de>) and check it with Send test email. Port 465 connects via TLS, other ports via STARTTLS; without encryption, no password is sent. The access applies only to this instance – you set it up separately for local and live (in remote mode, directly for the server).

IllusionDB

IllusionDB manages existing databases (PostgreSQL, MySQL/MariaDB, SQLite) with a user interface and REST API. The IllusionDB node fetches records directly from there – products, teams, events, detail pages – without copying them into a Collection.

Setting up a connection: Settings → IllusionDB: name, address (e.g. https://db.firma.de) and API token (in IllusionDB under Settings → My account; read access to the tables is enough for reading). Test connection shows the version and how many tables are readable. Without a token, only tables that are publicly readable in IllusionDB are reachable.

  • Workflows refer to the connection by its name, and to tables by their name (for tables with the same name in several data sources, Datenquelle/tabelle). Create one connection with the same name both locally and live – the same Workflow then uses the matching database in each place.
  • The connections belong to the instance, not to the bundle; the token is never delivered. In live mode you edit those of the server.

The node: It appears in the palette only once at least one connection is set up. Connection, table, columns and saved views come live from the IllusionDB server as selections (↻ reloads the schema, e.g. after new columns).

Action Result in msg.payload
read records List – with filter, sorting, grouping, summary, count and page, optionally based on a saved view
read one one record by primary key or by a column (e.g. slug); not found: null or, optionally, an error
create / change / delete Columns from msg.payload, primary key by default {{ msg.payload.id }}; result: the saved record
Schema the table with all columns (name, type, primary key, …)
list tables all readable tables of all data sources
  • Filter: rules column – comparison – value (=, ≠, >, ≥, <, ≤, contains, one of a, b, c, none of, is empty, is not empty), combined with all rules or at least one. Values may contain {{ msg.… }}; numbers and yes/no are converted to match the column type. Rules whose value stays empty are dropped – so an empty search field doesn’t filter (can be turned off).
  • Filter as JSON replaces the rules, for nested and/or conditions in the format of the IllusionDB API: {"or": [{"col": "name", "op": "like", "val": "{{ msg.payload.q }}"}, {"col": "preis", "op": "lt", "val": {{ msg.payload.max }}}]}. A placeholder in quotation marks is inserted as text, without quotation marks as a JSON value (number, true, list).
  • Only these columns (when reading lists and single records): e.g. slug, name, preis – IllusionDB sends only these columns and skips computing formula, lookup and link fields that aren’t needed; for wide or large tables this saves data volume and time. An unknown column name is an error. Empty = all (older IllusionDB versions always return all).
  • Pages: Count (at most 1000) and Page (e.g. {{ msg.payload.seite }}) – or load all pages (up to 10,000 records).
  • Output: the list of records; everything = records, total, page, pages, has_more, summary, groups; only the groups; or the first record.
  • Groups: per group value, count (over all matches, not just the loaded page) and records – with several grouping columns, groups one level deeper instead.
  • Summary: SUM, AVG, COUNT, MIN, MAX over all matches, in summary.<spalte>. IllusionDB calculates SUM and AVG only for columns of type Number or Decimal.
  • Remember result: a read result is cached for this many seconds – relieves the database under heavy traffic (0 = ask every time). The CMS remembers tables and columns for one minute.
  • Column names are the names IllusionDB uses in its API (display name, if set).

List and detail pages directly from the database – like the blog in chapter 10, only with IllusionDB instead of a Collection. Two Workflows:

  • HTTP in (GET, path produkte) → IllusionDB (read records, table produkte, rule aktiv = true, sorting name, grouping kategorie, count 20, page {{ msg.payload.seite }}, output everything) → HTTP response
  • HTTP in (GET, path produkt) → IllusionDB (read one, table produkte, find by column slug, value {{ msg.payload.slug }}) → HTTP response

Plus a page /produkte with Catch-All and Disable cache:

{% if route.slug %}
  {% set p = workflow("produkt", "slug", route.slug) %}
  {% if not p %}{{ not_found() }}{% endif %}
  <h1>{{ p.name }}</h1>
  <p>{{ p.preis }} € · {{ p.kategorie }}</p>
  <a href="/produkte">← Alle Produkte</a>
{% else %}
  {% set r = workflow("produkte", "seite", route.query.seite|default:"1") %}
  {% for g in r.groups %}
    <h2>{{ g.value }} ({{ g.count }})</h2>
    {% for p in g.records %}<a href="/produkte/{{ p.slug }}">{{ p.name }}</a>{% endfor %}
  {% endfor %}
  <p>Seite {{ r.page }} von {{ r.pages }} · {{ r.total }} Produkte</p>
  {% if r.has_more %}<a href="?seite={{ r.page + 1 }}">weiter</a>{% endif %}
{% endif %}
  • /produkte/mate shows the record with slug = mate, an unknown slug the 404 page (read one then returns null).
  • With Disable cache, every change in IllusionDB is visible immediately; without it, the page cache keeps the page for at most one minute. With heavy traffic: leave the cache on and set Remember result in the node.
  • Sitemap: In the page’s settings under Detail pages for sitemap and static export, choose the source IllusionDB table (connection shop, table produkte, column slug, rule aktiv = true) – see chapter 10. With Check you see right away how many pages there will be.

Thousands of landing pages: The pattern also handles large quantities – for example a table landingpages with columns slug (berlin/zahnarzt), stadt, leistung, text, aktiv, geaendert, maintained in IllusionDB (or imported there via CSV). One Catch-All page /leistungen with one template, one Workflow read one by slug, the sitemap source on the same table – and all pages including the sitemap are done. New rows in IllusionDB are online immediately (with Disable cache) and in the sitemap after 10 minutes at the latest.

In the Function node, idb is available – for everything that can’t be clicked together with the node:

const r = idb.records('shop', 'produkte', {
  where: { and: [{ col: 'aktiv', op: 'eq', val: true }, { col: 'preis', op: 'lt', val: 5 }] },
  sort: '-preis', // several: 'kategorie,-preis'
  limit: 50,
})
msg.payload = r.records.map((p) => `${p.name}: ${p.preis} €`)
return msg

idb.records(verbindung, tabelle, { where, sort, group_by, summary, fields, view, limit, page, all }) returns the same as the output everything; in addition idb.record(v, t, pk) (or null), idb.find(v, t, spalte, wert) (first match or null), idb.create(v, t, daten), idb.update(v, t, pk, daten), idb.delete(v, t, pk), idb.schema(v, t), idb.tables(v) and idb.api(v, methode, pfad, body) for any other endpoint of the IllusionDB API (path without /api/v1). Errors from IllusionDB abort the run with the message.

Custom Nodes

If you write the same script over and over (format prices, shorten text, send a notification to a service), turn it into a custom node: written once, it can be dragged from the palette (group Custom) into any Workflow – with settings per use and named outputs. If the script changes, that takes effect in all Workflows.

They are created and edited under Workflows → Custom nodes (top right):

  • Name, description, color – this is how the node appears in the palette and on the canvas; the description is its help text.
  • Input: with input processes messages like a Function node. Without input is a start node – with ▶ and an optional schedule like Start, e.g. “Fetch feed”.
  • Outputs: 0 to 10, each with an optional label (“ok”, “error”) shown on the node.
  • Parameters: fields that are filled in per use in the Workflow – Text, Text, multi-line, Number, Yes/No or Selection (with options), each with a default value and help text. In the code they are in config.<name>; in texts, {{ msg.… }} is replaced beforehand.
  • Code: JavaScript as in the Function node – with msg and config, returning msg, [a, null, b] per output, or null. Everything else (fetch, store, ic.*, idb.*, node.warn) is available as well.
  • Testing: Enter parameters and a sample message; Run shows what comes out of each output – even unsaved, without an entry in the runs.

Example Format price – parameters waehrung (selection €, $, default €) and stellen (number, default 2), outputs “ok” and “ohne Preis”:

const preis = Number(msg.payload.preis)
if (msg.payload.preis == null || isNaN(preis)) return [null, msg]
msg.payload.preisText = preis.toFixed(config.stellen).replace('.', ',') + ' ' + config.waehrung
return [msg, null]
  • Workflows refer to it by its key (derived from the name when created, e.g. preis-formatieren) – so renaming is safe.
  • A node can only be deleted once no Workflow uses it anymore; History shows earlier versions of the code.
  • Custom Nodes are part of the content: they go into the bundle and thus onto the live server when uploading.

Limits and security

  • Time limits: 30 seconds for flows with an HTTP input, 5 minutes otherwise; at most 1,000 node executions per run (protection against loops in the graph). If a flow calls itself via its own hook, it stops after three levels.
  • Workflows run with full permissions: they can change all content and, via HTTP requests, also reach addresses in the internal network. Anyone who edits Workflows needs the same trust as for HTML and templates.
  • The SMTP password is stored in the instance’s database (not in bundles, never shown again in the admin).
  • Credentials are stored in the Node or in the script – and therefore also in bundles and in the version history. Do not hand out bundles and backups. Environment variables of the server are deliberately not available in Workflows.
  • Public inputs are reachable by anyone – validate input, don’t return internal data.
  • Collection entries have no version history. If you change many entries via a Workflow, download a bundle as a backup beforehand.