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. Spotify and moderation steps run in the API process through the dashboard's services instead (see Spotify and moderation steps).
Frontend
- Node-based visual editor with a drag-and-drop canvas for creating and arranging nodes.
- Edge connections define the execution flow between nodes.
- The toolbar's Action menu lists OBS Control (
obs_control) and Streamer.bot (streamer_bot) with a Not available yet hint. They cannot be added, because no Lumio service carries these actions out yet (see Action delivery). The list lives inUNAVAILABLE_ACTION_TYPES(shared/automation/src/utils.ts). - A new Spotify node starts as Play and a new Moderation node as a 600-second Timeout (
getDefaultConfig), so a node saved untouched carries what its panel shows. The Spotify panel asks for the volume (0–100) when the action is Volume; the Moderation panel offers Timeout, Ban and Delete and asks for the duration in seconds for a timeout. - An automation saved with one of these nodes still opens unchanged. The node shows a Does not run yet badge and its settings panel a notice that the step does not run yet; a run fails when it reaches the step.
- 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. - Automation settings in the editor header, shown to members with
automations:edit, include On test events. Prefix is the default; Ignore prevents a test event from starting this automation. This setting applies to incoming test events, not to the editor's manual Test run. - 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, enabled state or testEvents (prefix or ignore). 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 (the editor's Test run). Starts at the manual_trigger node, or at the earliest stored trigger when there is none, and runs it whether or not the automation is enabled (see Data Flow). 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 or test_events (prefix or ignore) |
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, test_events (prefix by default), 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). This is the editor's Test run, and it returns the result inline. Both protocols apply the same rules:- The run starts at the
manual_triggernode. Without one it starts at the earliest stored trigger of any kind (ties broken by node id), so a graph with several triggers always starts at the same node. - It runs disabled automations too, so a draft can be tested before it is switched on, and it works whatever the trigger switch says.
- It runs with an empty template context: variables the trigger would supply (
{{user}}, the event fields) have no value. - A graph without any trigger fails with
No trigger node found in automation(REST400 BAD_REQUEST, GraphQL error with the same message). - Whoever holds
automations:executecan therefore run every action of every automation in the account, including actions such as moderation that need their own permission elsewhere. Grantautomations:executeonly to members who may run all of the account's automations. Saving such a node needs that permission (see Nodes that act as the channel owner). - A moderation step needs a target user from the trigger, so in a manual run it fails with
Invalid config: Moderation needs a target user from the trigger context.
- The run starts at the
- 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.
An incoming test event has event.raw.isTest: true. An automation with test_events: ignore does not start from it. Under the default prefix mode, chat_message and discord_post start their output with [Test] ; the moderation step is always skipped. overlay_alert, obs_control, spotify_control and streamer_bot steps run only when the test was sent with Show on stream; otherwise their step status is skipped. Webhooks, variables and extension nodes still run, and webhook and extension-node input includes event.raw.isTest. This is distinct from the editor's manual Test run, which executes a saved graph without an incoming event.
- 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.
Command trigger gates
A command_trigger runs only when both of its gates pass. They apply to a !command chat message and to a bot command with response_type = automation alike.
| Gate | Config field | Behaviour |
|---|---|---|
| Role | permission_level (everyone < follower < subscriber < vip < moderator < broadcaster, default everyone) | The sender's role must be at least this level. For a chat message the role comes from the message (broadcaster badge, then is_mod, is_vip, is_sub). Follower status is not known from chat, so follower admits subscribers and above only. For a bot command it is the user.role the bot command registry sends. That gate applies on top of the bot command's own role gate. A missing or unknown sender role counts as everyone. An unknown stored permission_level admits only the broadcaster |
| Cooldown | cooldown_seconds (default 0 = none, at most 86,400 s = one day) | One run per trigger node per window, for all senders together. The slot is the Redis key lumio:automation_cd:{account_id}:{automation_id}:{node_id}, taken with SET NX EX cooldown_seconds once the run holds its slot in the account's execution cap, so a run dropped at the cap does not use up the cooldown. A run inside the window is skipped. If Redis is unreachable, the run is skipped too |
The role gate runs first, so a sender below the level never uses up the cooldown. A blank keyword, command or alias never matches, whatever its match mode. Save-time validation rejects these values; a row saved before that rule stays inert.
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, or — for Spotify and moderation, which run in the API process — when the service carried it out. 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 |
spotify_control | services::spotify_control in the API, with the channel's Spotify connection | Delivered. Fails when Spotify is not connected or the plan lacks feature:music |
moderation | services::chat_moderation in the API, as the channel owner (Twitch, YouTube, Kick) | Delivered. Fails when the owner has no valid login for the platform or the plan lacks feature:multichat |
obs_control | — | Fails with no consumer |
streamer_bot | — | Fails with no consumer |
The editor offers neither of the two no consumer actions above (obs_control, streamer_bot); it marks them Not available yet.
Spotify and moderation steps
Both steps call the services the dashboard uses, so a step behaves like the same click in the dashboard. Tokens come only from the central refresh helpers (get_fresh_connection_token for Spotify, the login-token service for moderation); a step never refreshes a token itself.
Spotify (spotify_control, config action and, for volume, volume_percent):
action | Effect |
|---|---|
play | Resume playback |
pause | Pause playback |
next | Skip one track |
previous | Go back one track |
volume | Set the volume to volume_percent (0–100) |
- The step uses the channel's Spotify connection; it needs no signed-in member. Without a connection it fails with
Spotify not connected. Connect Spotify in your dashboard settings. - The node is mapped onto the extension actions' control table (
services::extension_actions::spotify_control_from_params;previous, which has no extension action, maps directly), so an invalid value fails the same way:volumewithoutvolume_percentor above 100, or an unknown action, fails withInvalid config: …. - It records the same
spotify:*event as the dashboard and extension actions, so the event feed and Spotify widgets show it. The event'srawcarriesvia: "automation"andautomation_id.
Moderation (moderation, config action, duration_seconds, reason):
- The step moderates as the channel owner (
accounts.owner_id), with the owner's own platform login, because an automation has no signed-in member. It runs on the platform of the trigger (channel.platform;twitchwhen the trigger names none). timeoutandbantarget the user who triggered the automation (user.user_id); a timeout withoutduration_secondslasts 600 seconds.deleteremoves the chat message that triggered it, so it needs a chat, keyword or command trigger. Any other action fails withInvalid config: Unsupported moderation action: ….- A refused action fails the step and never counts as a success: no owner (
Channel has no owner), no owner login for the platform (No Twitch login connection. Connect your Twitch account first.), or a refusal with its code, e.g.login_reconnect_required: Your Twitch login must be reconnected. Log in with Twitch again. - A successful action writes the moderation log entry and broadcasts like a dashboard action. The log entry names the owner as moderator, and its
detailscarryvia: "automation",automation_idandautomation_name. In the user card's Moderation history, the entry shows by ‹owner› · by automation ‹name›. Chat moderation writes no audit event, from the dashboard or from an automation.
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.
Credentials in webhook nodes
Webhook nodes hold credentials for other services, so these config values are write-only:
| Node type | Write-only values |
|---|---|
webhook_trigger | secret |
webhook_call | every value in headers, the userinfo of url (https://user:password@…), and every query and fragment value of url |
Every read returns each of these values as "[redacted]": GraphQL automation and saveAutomationNodes, and REST GET /v1/automations/{id} and PUT /v1/automations/{id}/nodes. null and empty values stay as they are, so the editor can tell a set value from an unset one. Header names, the URL's host and path, and query keys stay readable.
On save (saveAutomationNodes / PUT /v1/automations/{id}/nodes), a "[redacted]" value means "keep the stored value". The API matches it to the stored node with the same id and node type: a header by name (case-insensitive), a query or fragment value by key and position, and the secret and the URL userinfo directly. A loaded graph can therefore be saved back unchanged, and editing one header leaves the others intact. A placeholder with nothing stored to keep (a new or copied node, a changed node type, a header that did not exist) fails the whole save with the same 400 on both protocols, Automation node <id>: the "[redacted]" placeholder in <field> has no stored value to keep; enter the value again, and writes nothing. Both protocols share apps/api/src/services/automation_secrets.rs.
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 |
At most 200 nodes (an automation can have at most 200 nodes) and 400 edges (… at most 400 edges) | nodes / edges |
A node's config is at most 16 KiB serialized (node …: config exceeds 16384 bytes) | nodes |
command_trigger: command and every alias are not blank, permission_level is one of everyone, follower, subscriber, vip, moderator, broadcaster (any case), cooldown_seconds is at most 86,400 | nodes |
keyword_trigger: no keyword is blank. In regex mode each keyword is at most 200 characters and compiles (invalid regex otherwise) | 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.
Nodes that act as the channel owner
A moderation node moderates with the channel owner's login, and a Spotify node drives the channel's Spotify connection. Saving one therefore needs the permission it acts with, on top of automations:edit, so a member cannot act as the owner beyond their own rights:
| Node | Needs |
|---|---|
moderation with action ban | chat:ban |
moderation with action timeout | chat:timeout |
moderation with action delete | chat:delete |
moderation with any other action | chat:ban |
spotify_control | spotify:playback |
spotify_control with action volume | spotify:playback and spotify:volume |
- The check runs in
services::automations::save_nodes, sosaveAutomationNodesandPUT /v1/automations/{id}/nodesrefuse with the same403 FORBIDDEN,Missing permission: <permission>, and write nothing. The MCPlumio_automation_nodes_savetool goes through the same service after its own, stricter save table (it refuses a ban node outright). The table isowner_acting_permissionsinapps/api/src/services/mcp/automation_rules.rs, next to the MCP-only rules. - A node stored unchanged (same id, type and config) is not checked again. A member without
chat:bancan still edit other nodes of an automation the owner built; changing the ban node is checked. - Saved nodes keep running whoever saved them; the check applies at save time only.
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/automations.rs | Create, update, delete and node/edge save shared by GraphQL and REST: ownership check, restore of redacted webhook credentials, save-time validation, webhook reconcile |
apps/api/src/services/automation_secrets.rs | Write-only webhook credentials: redaction on every read, placeholder restore on save (see Credentials in webhook nodes) |
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 |