Skip to main content

Automations

Overview​

The Automations module provides a visual workflow automation engine with a node-based editor. Users create automations composed of trigger nodes, condition nodes, and action nodes connected by edges. Automations can be triggered by platform events (e.g., a new follower triggers a chat message) or manually. The module includes an execution engine that walks the node graph, evaluates conditions, executes actions via a dispatcher, and supports template variables. A manual run reports its outcome inline as ExecutionResultGql (automationId, completed, steps, failedSteps, stepResults); runs are not persisted to an execution-history table.

Architecture​

Backend​

  • GraphQL (apps/api/src/graphql/automations.rs) -- Full CRUD for automations, nodes, and edges. Manual execution mutation that builds and runs the execution graph.
  • REST (apps/api/src/routes/automations.rs) -- parity endpoints under /v1/automations.
  • Database (apps/api/src/db/automations.rs) -- PostgreSQL operations for automations, automation_nodes, and automation_edges tables. Supports bulk replace for nodes and edges.
  • Execution Engine (crates/lo-automation/src/) -- ActionExecutor walks the execution graph from a trigger node, evaluates conditions, and dispatches actions. TemplateContext provides variable interpolation. NodeType enum defines available node types (triggers, actions, logic).
  • Dispatcher (apps/api/src/dispatch.rs) -- RedisActionDispatcher sends action payloads via Redis pub/sub for real-time execution.

Frontend​

  • Node-based visual editor with a drag-and-drop canvas for creating and arranging nodes.
  • Edge connections define the execution flow between nodes.
  • Test run button in the editor header (/dashboard/automations/{id}/editor), shown to members with automations:execute; the editor itself sits behind feature:automation. Unsaved edits are saved first, because the server always runs the saved graph — if the save fails, the run does not start.
  • The run goes through the web proxy POST /api/automations/{id}/execute (the executeAutomation mutation) and its result appears in a panel under the header:
    • Green only when at least one step ran and none failed. Any failed step, or a walk that did not complete, shows the run as failed with "n of m steps failed"; a run that executed no step (nothing connected to the trigger) is neutral, not green.
    • Every executed step is listed in order with its node, status (succeeded / skipped / failed), duration and — for a failed step — the server's error, e.g. No consumer for action control_obs. Selecting a step opens that node's settings.
    • On the canvas each executed node gets an outline in its step's colour (red = failed, green = succeeded, grey = skipped). A node that ran more than once (loop) shows its worst outcome. The outline is display-only and never marks the automation as changed.
    • A request-level error (for example No trigger node found in automation) is shown in the same panel. Dismissing the panel clears the outlines.

API​

GraphQL Queries​

Every automation query and mutation additionally requires the feature:automation feature flag on the account. When the flag is off the operation fails on both protocols before any permission check runs.

QueryPermissionDescription
automationsfeature:automation + automations:readList all automations for the account
automation(id: UUID!)feature:automation + automations:readGet a single automation with all its nodes and edges
automationWebhookUrl(installId: UUID!, automationId: UUID!)feature:automationWebhook URL + secret for an extension trigger node (see Webhook triggers)

GraphQL Mutations​

MutationPermissionDescription
createAutomation(input: CreateAutomationInput!)automations:createCreate a new automation with name and description. Returns AutomationGql!.
updateAutomation(input: UpdateAutomationInput!)automations:editUpdate automation name, description, or enabled state. Returns AutomationGql!.
deleteAutomation(id: UUID!)automations:deleteDelete an automation and all its nodes/edges. Returns AutomationDeleteResult!.
saveAutomationNodes(automationId: UUID!, nodes: [AutomationNodeInput!]!)automations:editBulk replace all nodes for an automation. Returns [AutomationNodeGql!]!.
saveAutomationEdges(automationId: UUID!, edges: [AutomationEdgeInput!]!)automations:editBulk replace all edges for an automation. Returns [AutomationEdgeGql!]!.
executeAutomation(id: UUID!)automations:executeManually execute an automation. Finds the manual_trigger node (or first trigger), builds the execution graph, and runs it. Returns ExecutionResultGql! with completed, steps, failedSteps and per-step stepResults (nodeId, nodeType, status = success/skipped/error, error, durationMs).

Built-in node types​

nodeType on a saved node is one of the NodeType variants in crates/lo-automation/src/types.rs:

GroupNode types
Triggercommand_trigger, keyword_trigger, event_trigger, timer_trigger, manual_trigger, webhook_trigger, extension_trigger
Actionchat_message, discord_post, obs_control, streamer_bot, spotify_control, overlay_alert, webhook_call, moderation, set_variable, get_variable, extension_action
Logiccondition, delay, random, loop, extension_logic

The three extension_* variants are the dispatch points for extension nodes.

Input Types​

AutomationNodeInput:

FieldTypeDescription
idUUIDNode ID (client-generated)
nodeTypeStringNode type (trigger, condition, action)
positionXf64X position in the editor canvas
positionYf64Y position in the editor canvas
configJSONNode-specific configuration (default: {})

AutomationEdgeInput:

FieldTypeDescription
idUUIDEdge ID (client-generated)
sourceNodeIdUUIDSource node ID
targetNodeIdUUIDTarget node ID
sourceHandleStringSource handle name (default: "output")
targetHandleStringTarget handle name (default: "input")

REST Endpoints​

Mirror the GraphQL surface. All paths live under /v1/automations.

MethodPathPermissionDescription
GET/v1/automationsautomations:readList automations
POST/v1/automationsautomations:createCreate an automation
GET/v1/automations/{id}automations:readGet an automation (with nodes and edges)
PATCH/v1/automations/{id}automations:editUpdate name/description/enabled
DELETE/v1/automations/{id}automations:deleteDelete automation + nodes + edges
PUT/v1/automations/{id}/nodesautomations:editBulk replace node list
PUT/v1/automations/{id}/edgesautomations:editBulk replace edge list
POST/v1/automations/{id}/executeautomations:executeRun the automation manually. Returns completed, steps, failed_steps and per-step step_results (same fields as GraphQL, snake_case)

Request bodies are snake_case copies of the GraphQL CreateAutomationInput / UpdateAutomationInput / AutomationNodeInput / AutomationEdgeInput.

WebSocket​

ChannelGateFeature flag
automations:{account_id}automations:read on the account in the channel keyfeature:automation

An account without feature:automation is rejected at subscribe time, so the stream cannot be reached by crafting the channel name. The gate lives in channel_gate_for / channel_feature_for (crates/lo-websocket/src/gate.rs).

Permissions​

PermissionDescription
automations:readView automations and their configuration
automations:createCreate new automations
automations:editEdit automation metadata, nodes, edges
automations:deleteDelete automations
automations:executeManually trigger and start/stop automations

There is no automations:history permission: no execution-history surface exists — executeAutomation returns its results inline and they are not persisted. The former automations:history account permission was removed in ZAF-1094 (it gated nothing).

Database​

TableDatabaseDescription
automationsPostgreSQLid, account_id, name, description, enabled, created_at, updated_at
automation_nodesPostgreSQLid, automation_id (FK), node_type, position_x, position_y, config (JSONB), created_at
automation_edgesPostgreSQLid, automation_id (FK), source_node_id, target_node_id, source_handle, target_handle, created_at

Data Flow​

An automation runs in one of two ways:

  • Manually: POST /v1/automations/{id}/execute (REST) or executeAutomation (GraphQL). Manual execution starts at the manual_trigger node, or at the first trigger node when there is none, and returns the result inline. It works whatever the trigger switch says.
  • From a trigger: chat, keyword, command, event and timer triggers fire only when the operator has set [automation] triggers_enabled = true (LUMIO__AUTOMATION__TRIGGERS_ENABLED). The switch is off in every shipped config. With it off, trigger nodes are saved but never evaluated. See Installation → Automation triggers.
  1. User creates an automation and adds nodes (triggers, conditions, actions) via the visual editor.
  2. Nodes are connected with edges that define execution flow.
  3. Nodes and edges are saved via bulk-replace mutations (saveAutomationNodes, saveAutomationEdges).
  4. When the automation runs:
    • The execution graph is built from nodes and edges.
    • The executor starts at the trigger node and walks the graph.
    • Conditions are evaluated; actions are dispatched via RedisActionDispatcher (see Action delivery).
    • Each step is recorded with its status for the execution result.
  5. A manual run returns whether the walk completed, how many steps ran, how many failed, and each step's status and error. A triggered run publishes {automation_id, trigger_node_id, completed, steps_count, error} to lumio:automations:{account_id}.

Trigger path​

Twitch announcements follow the ordinary chat-message path and can match keyword and command chat triggers; there is no announcement-specific exclusion.

With triggers_enabled = true, every API replica runs the automation trigger supervisor (apps/api/src/workers/automation_triggers.rs). Only the replica that holds the Redis lease lumio:automation:triggers:leader runs workers, so a trigger fires once, not once per replica. The lease lives for 30 s and is renewed every 10 s. A replica that loses it stops its automation workers, and another replica takes over within one lease lifetime.

Every 10 s the leader starts or stops per-account workers to match the database. Accounts whose plan lacks feature:automation get no workers.

WorkerRuns forFires
Trigger worker (workers/automation.rs)Each account with an enabled automationSubscribes to lumio:chat:{account_id} and lumio:events:{account_id}. A chat message fires keyword_trigger and !command command_trigger nodes, and a stream event fires event_trigger nodes. A bot command with response_type = automation is published on the same chat channel as {"type": "command", "automation_id", …}, and runs that automation from its command_trigger node
Timer worker (workers/automation_timer.rs)Each account with an enabled timer_triggerChecks every second and fires each timer once per interval_seconds. A newly seen timer waits one full interval before its first fire, also after a restart or a leader change

Each account reloads its enabled automations at most every 5 s, so an edit takes effect within 5 s.

A Twitch Shared Chat partner message or event never fires a trigger. These are messages written in another channel's room and delivered into this one. They are still displayed. The Twitch bot marks such a bot command is_shared_chat. The bot does not publish it, and the trigger worker skips it too.

Each triggered execution runs in its own task, so a long Delay node never holds up the next trigger:

LimitValueWhen exceeded
Executions per account at oncemax_concurrent_executions (default 8), trigger and timer runs togetherThe trigger is dropped and logged, not queued
Wall-clock time per executionexecution_timeout_secs (default 3900 s, above the 1-hour Delay cap)The run is stopped and publishes completed: false with Execution timed out after … seconds
Worker shutdown or lost lease—Running executions of that worker stop at once

Action delivery​

A step is success only when a consumer received the action. Bot actions are published to lumio:bot:action:{platform}:{account_id} as a { "type": …, "data": … } envelope (lo_cache::bot_action::BotActionCommand); the API and every bot share that type and its type-name constants. A step fails with an error status when:

  • no Lumio service consumes the action (No consumer for action …), or
  • the Redis PUBLISH reached no subscriber, for example because the bot is not running (No subscriber received action …).
Action nodeDelivered toToday
chat_messageTwitch, Kick or YouTube bot (chat_message; platform defaults to twitch)Delivered. Any other platform fails with no consumer
discord_postDiscord bot (post_message)Delivered
overlay_alertAPI event relay → WebSocket automations:{account_id}Delivered
webhook_callThe configured URL over HTTP (see Webhook call targets)Fails with webhook_failed or webhook_http_error
set_variable / get_variableRedis keys (lumio:var:{account_id}:{name}, 30-day TTL refreshed on every write)Delivered
extension_action / extension_logicAutomation Worker over HTTPFails when the worker is unconfigured or the handler errors
moderation—Fails with no consumer: no bot executes moderation yet (the Twitch bot refuses it until the Helix moderation path ships)
obs_control—Fails with no consumer
streamer_bot—Fails with no consumer
spotify_control—Fails with no consumer

Webhook call targets​

The webhook_call node sends its request from the Lumio API, so every target passes a target filter before a connection opens (apps/api/src/webhook_target.rs). The same filter runs for manual, event- and timer-triggered executions.

RuleDetail
Schemehttps or http only. Plain http stays allowed for self-hosted receivers without TLS; prefer https, because headers and body travel unencrypted otherwise
AddressThe host is resolved once. The target is refused when any address is not public: loopback (127.0.0.0/8, ::1, localhost), private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7), link-local (169.254.0.0/16, fe80::/10), CGNAT (100.64.0.0/10), unspecified, multicast, broadcast and reserved ranges, and IPv6 forms that wrap one of these (IPv4-mapped, NAT64, 6to4)
PinningThe request connects only to the addresses that were checked; the client does no DNS lookup of its own, so a changed DNS answer cannot redirect it
RedirectsNever followed. A 3xx answer is a non-2xx answer
ProxySystem proxy settings are ignored for these requests
Timeouts10 s to connect, 60 s in total

Substituted template values in the URL, headers and body are encoded for their position (lo_automation::template::Sink): percent-encoding after the URL's literal authority, control characters in header values replaced by spaces, and JSON-string escaping or form encoding of the body by its Content-Type (a body template starting with { or [ counts as JSON when no Content-Type is set). Discord post messages escape markdown in substituted values. The full table is in Automation Templates → Output Encoding.

A failed webhook step reports only one of two fixed errors, on GraphQL executeAutomation and REST POST /v1/automations/{id}/execute alike:

ErrorMeaning
webhook_failedThe request was not delivered: the target was refused by the filter, the URL is invalid, the host did not resolve, or the connection, TLS handshake or timeout failed
webhook_http_errorThe target answered with a status outside 2xx

A refused target and an unreachable one return the same webhook_failed. The result never contains the URL, a status code or transport error text; the API logs the detail server-side (without the URL). A target refused during a manual run also writes an account:webhook_target_blocked audit event; event- and timer-triggered runs only log it, because they would repeat it on every trigger.

Graph rules and limits​

Save-time validation​

saveAutomationNodes / PUT /v1/automations/{id}/nodes and saveAutomationEdges / PUT /v1/automations/{id}/edges validate the graph before anything is written. Both protocols run the same check (lo_automation::validate_graph, called from apps/api/src/services/automation_graph.rs) and reject with the same 400 BAD_REQUEST and message (Graph error: …).

RuleChecked on
Node ids are uniquenodes
random nodes have min <= maxnodes
timer_trigger nodes have interval_seconds of at least 60 (timer trigger …: interval_seconds must be at least 60)nodes
set_variable names are at most 128 bytes, and one automation writes at most 100 distinct namesnodes
extension_* nodes reference an install of this account, for the extension named in extension_meta (Extension install not found for this account otherwise)nodes
No self-loop (an edge from a node to itself)edges
No duplicate edge (same source, target and source handle)edges
No edge to or from an unknown nodeedges
No directed cycle through a trigger nodeedges

Cycles between non-trigger nodes are allowed; the run-time budget bounds them. A Loop node repeats its subtree through its count, not through a back-edge.

Execution limits​

The executor (crates/lo-automation/src/executor.rs) enforces these limits on every run, whatever is stored:

LimitValueWhen exceeded
Node entries per run (MAX_STEPS)1,000, trigger nodes reached mid-walk includedThe walk stops: the last step fails with Limit exceeded: automation work budget exhausted … and completed is false
Recursion depth (MAX_DEPTH)100Same as above
Trigger node entriesOnce per run; a path back into a trigger ends there—
Loop iterations (MAX_LOOP_ITERATIONS)100 per Loop nodeThe count is clamped
Delay (MAX_DELAY_MS)1 hour per Delay nodeThe delay is clamped
Rendered template (MAX_TEMPLATE_OUTPUT_BYTES)16 KiB per template (chat message, Discord/overlay message, webhook URL, header or body, condition operand, variable value)The step fails with Limit exceeded: rendered template exceeds 16384 bytes. Chat bots still truncate to the platform's own message limit
Variable value (MAX_VARIABLE_VALUE_BYTES)4 KiBThe step fails; nothing is stored
Variables per run (MAX_VARIABLES_PER_AUTOMATION)100, extension node output includedThe step fails
Stored variable lifetime (VARIABLE_TTL_SECS)30 days, refreshed on every writeThe Redis key expires

A random node stored with min > max (saved before validation existed) uses the swapped range instead of failing. A timer_trigger stored with an interval below 60 s fires every 60 s.

Key Files​

PathDescription
apps/api/src/graphql/automations.rsGraphQL queries and mutations
apps/api/src/db/automations.rsDatabase CRUD for automations, nodes, edges
crates/lo-automation/src/Execution engine and limits (executor.rs), save-time graph validation (validate.rs), template context, node types (types.rs)
apps/api/src/services/automation_graph.rsSave-time validation shared by GraphQL and REST (graph rules + extension install ownership)
apps/api/src/routes/automations.rsREST endpoints
apps/api/src/dispatch.rsRedisActionDispatcher for action execution (action → consumer inventory)
crates/lo-cache/src/bot_action.rsShared bot action envelope and type-name constants
apps/api/src/workers/automation.rsbuild_execution_graph helper

Extension Nodes​

Third-party developers can create custom automation nodes via the Extension Platform. The Automation Builder does not list installed extension nodes in its toolbar yet, so a new extension node cannot be added from the dashboard. An automation that already contains an extension node (for example one saved over the API) shows it on the canvas; selecting it shows a note that extension nodes cannot be configured in the builder yet.

Node types​

TypeDescription
TriggerStarts an automation from an external event (webhook or polling)
ActionPerforms work during automation execution
LogicBranches the flow based on a condition

Extension nodes execute in V8 isolates inside the Automation Worker service. Each node has a 2-5 second timeout depending on type, enforced inside the worker (see the developer docs' Runtime limits).

An extension node names its install in config.extension_meta.install_id. The install must belong to the account that owns the automation: the save paths reject any other install, and the Automation Worker resolves the install by (install_id, account_id, extension_id) at run time, where account_id comes from the automation row. A mismatch fails the step with Install not found. Installs of a suspended extension are dropped from the worker's registry on its next sync (at most about 30 seconds).

Installation​

Extension automation nodes are installed from the Extension Store like any other extension. Uninstalling removes the node from the toolbar and disables any automations that use it (with a warning notification).

Feature flag​

Extension automation nodes require the feature:automation_node_extensions feature flag. This flag gates:

  • Installation of automation_node extensions in the store
  • The "Extensions" section of the Automation Builder toolbar (the toolbar renders it only when the host passes installed nodes, which the dashboard does not do yet)

Webhook triggers​

Extension trigger nodes can receive external webhooks. When an automation with a webhook trigger is enabled:

  1. A webhook URL and secret are generated
  2. The user configures the external service with this URL and the X-Webhook-Secret header
  3. Incoming webhooks are validated and forwarded to the Automation Worker
  4. If the handler returns fired: true, the automation runs

Key files​

PathDescription
apps/automation-worker/Automation Worker HTTP service (V8 handler executor)
crates/lo-automation-worker/Automation Worker library crate
crates/lo-automation/src/executor.rsAutomation Engine with extension node dispatch