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/) --ActionExecutorwalks the execution graph from a trigger node, evaluates conditions, and dispatches actions.TemplateContextprovides variable interpolation.NodeTypeenum defines available node types (triggers, actions, logic). - Dispatcher (
apps/api/src/dispatch.rs) --RedisActionDispatchersends 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 withautomations:execute; the editor itself sits behindfeature: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(theexecuteAutomationmutation) 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.
| Query | Permission | Description |
|---|---|---|
automations | feature:automation + automations:read | List all automations for the account |
automation(id: UUID!) | feature:automation + automations:read | Get a single automation with all its nodes and edges |
automationWebhookUrl(installId: UUID!, automationId: UUID!) | feature:automation | Webhook URL + secret for an extension trigger node (see Webhook triggers) |
GraphQL Mutations
| Mutation | Permission | Description |
|---|---|---|
createAutomation(input: CreateAutomationInput!) | automations:create | Create a new automation with name and description. Returns AutomationGql!. |
updateAutomation(input: UpdateAutomationInput!) | automations:edit | Update automation name, description, or enabled state. Returns AutomationGql!. |
deleteAutomation(id: UUID!) | automations:delete | Delete an automation and all its nodes/edges. Returns AutomationDeleteResult!. |
saveAutomationNodes(automationId: UUID!, nodes: [AutomationNodeInput!]!) | automations:edit | Bulk replace all nodes for an automation. Returns [AutomationNodeGql!]!. |
saveAutomationEdges(automationId: UUID!, edges: [AutomationEdgeInput!]!) | automations:edit | Bulk replace all edges for an automation. Returns [AutomationEdgeGql!]!. |
executeAutomation(id: UUID!) | automations:execute | Manually 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:
| Group | Node types |
|---|---|
| Trigger | command_trigger, keyword_trigger, event_trigger, timer_trigger, manual_trigger, webhook_trigger, extension_trigger |
| Action | chat_message, discord_post, obs_control, streamer_bot, spotify_control, overlay_alert, webhook_call, moderation, set_variable, get_variable, extension_action |
| Logic | condition, delay, random, loop, extension_logic |
The three extension_* variants are the dispatch points for extension nodes.
Input Types
AutomationNodeInput:
| Field | Type | Description |
|---|---|---|
id | UUID | Node ID (client-generated) |
nodeType | String | Node type (trigger, condition, action) |
positionX | f64 | X position in the editor canvas |
positionY | f64 | Y position in the editor canvas |
config | JSON | Node-specific configuration (default: {}) |
AutomationEdgeInput:
| Field | Type | Description |
|---|---|---|
id | UUID | Edge ID (client-generated) |
sourceNodeId | UUID | Source node ID |
targetNodeId | UUID | Target node ID |
sourceHandle | String | Source handle name (default: "output") |
targetHandle | String | Target handle name (default: "input") |
REST Endpoints
Mirror the GraphQL surface. All paths live under /v1/automations.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/automations | automations:read | List automations |
POST | /v1/automations | automations:create | Create an automation |
GET | /v1/automations/{id} | automations:read | Get an automation (with nodes and edges) |
PATCH | /v1/automations/{id} | automations:edit | Update name/description/enabled |
DELETE | /v1/automations/{id} | automations:delete | Delete automation + nodes + edges |
PUT | /v1/automations/{id}/nodes | automations:edit | Bulk replace node list |
PUT | /v1/automations/{id}/edges | automations:edit | Bulk replace edge list |
POST | /v1/automations/{id}/execute | automations:execute | Run 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
| Channel | Gate | Feature flag |
|---|---|---|
automations:{account_id} | automations:read on the account in the channel key | feature: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
| Permission | Description |
|---|---|
automations:read | View automations and their configuration |
automations:create | Create new automations |
automations:edit | Edit automation metadata, nodes, edges |
automations:delete | Delete automations |
automations:execute | Manually 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
| Table | Database | Description |
|---|---|---|
automations | PostgreSQL | id, account_id, name, description, enabled, created_at, updated_at |
automation_nodes | PostgreSQL | id, automation_id (FK), node_type, position_x, position_y, config (JSONB), created_at |
automation_edges | PostgreSQL | id, 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) orexecuteAutomation(GraphQL). Manual execution starts at themanual_triggernode, 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.
- User creates an automation and adds nodes (triggers, conditions, actions) via the visual editor.
- Nodes are connected with edges that define execution flow.
- Nodes and edges are saved via bulk-replace mutations (
saveAutomationNodes,saveAutomationEdges). - 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.
- 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}tolumio: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.
| Worker | Runs for | Fires |
|---|---|---|
Trigger worker (workers/automation.rs) | Each account with an enabled automation | Subscribes 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_trigger | Checks 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:
| Limit | Value | When exceeded |
|---|---|---|
| Executions per account at once | max_concurrent_executions (default 8), trigger and timer runs together | The trigger is dropped and logged, not queued |
| Wall-clock time per execution | execution_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
PUBLISHreached no subscriber, for example because the bot is not running (No subscriber received action …).
| Action node | Delivered to | Today |
|---|---|---|
chat_message | Twitch, Kick or YouTube bot (chat_message; platform defaults to twitch) | Delivered. Any other platform fails with no consumer |
discord_post | Discord bot (post_message) | Delivered |
overlay_alert | API event relay → WebSocket automations:{account_id} | Delivered |
webhook_call | The configured URL over HTTP (see Webhook call targets) | Fails with webhook_failed or webhook_http_error |
set_variable / get_variable | Redis keys (lumio:var:{account_id}:{name}, 30-day TTL refreshed on every write) | Delivered |
extension_action / extension_logic | Automation Worker over HTTP | Fails 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.
| Rule | Detail |
|---|---|
| Scheme | https or http only. Plain http stays allowed for self-hosted receivers without TLS; prefer https, because headers and body travel unencrypted otherwise |
| Address | The 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) |
| Pinning | The 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 |
| Redirects | Never followed. A 3xx answer is a non-2xx answer |
| Proxy | System proxy settings are ignored for these requests |
| Timeouts | 10 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:
| Error | Meaning |
|---|---|
webhook_failed | The 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_error | The 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: …).
| Rule | Checked on |
|---|---|
| Node ids are unique | nodes |
random nodes have min <= max | nodes |
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 names | nodes |
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 node | edges |
| No directed cycle through a trigger node | edges |
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:
| Limit | Value | When exceeded |
|---|---|---|
Node entries per run (MAX_STEPS) | 1,000, trigger nodes reached mid-walk included | The walk stops: the last step fails with Limit exceeded: automation work budget exhausted … and completed is false |
Recursion depth (MAX_DEPTH) | 100 | Same as above |
| Trigger node entries | Once per run; a path back into a trigger ends there | — |
Loop iterations (MAX_LOOP_ITERATIONS) | 100 per Loop node | The count is clamped |
Delay (MAX_DELAY_MS) | 1 hour per Delay node | The 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 KiB | The step fails; nothing is stored |
Variables per run (MAX_VARIABLES_PER_AUTOMATION) | 100, extension node output included | The step fails |
Stored variable lifetime (VARIABLE_TTL_SECS) | 30 days, refreshed on every write | The 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
| Path | Description |
|---|---|
apps/api/src/graphql/automations.rs | GraphQL queries and mutations |
apps/api/src/db/automations.rs | Database 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.rs | Save-time validation shared by GraphQL and REST (graph rules + extension install ownership) |
apps/api/src/routes/automations.rs | REST endpoints |
apps/api/src/dispatch.rs | RedisActionDispatcher for action execution (action → consumer inventory) |
crates/lo-cache/src/bot_action.rs | Shared bot action envelope and type-name constants |
apps/api/src/workers/automation.rs | build_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
| Type | Description |
|---|---|
| Trigger | Starts an automation from an external event (webhook or polling) |
| Action | Performs work during automation execution |
| Logic | Branches 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_nodeextensions 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:
- A webhook URL and secret are generated
- The user configures the external service with this URL and the
X-Webhook-Secretheader - Incoming webhooks are validated and forwarded to the Automation Worker
- If the handler returns
fired: true, the automation runs
Key files
| Path | Description |
|---|---|
apps/automation-worker/ | Automation Worker HTTP service (V8 handler executor) |
crates/lo-automation-worker/ | Automation Worker library crate |
crates/lo-automation/src/executor.rs | Automation Engine with extension node dispatch |