Every RSS reader eventually grows the same feature request: "when a post from this feed mentions that word, do something." Star it. Push me a notification. Fire a webhook at my homelab. You can bolt each of these on as a one-off setting, or you can admit what the users are actually asking for — a small automation engine — and build it properly.
SlashFeed took the second path. The result is a trigger → condition → action graph that users assemble on a visual canvas, stored as plain rows, and evaluated by a worker whenever a feed event happens. This post is about how that graph is modelled, queried, and edited, and the tradeoffs I made along the way.
The conceptual model: a graph, not a rule
The tempting design is a flat rule: one trigger, one condition, one action, three columns on a table. It falls apart the moment someone wants two actions, or a condition branch. So the model is a directed graph. Two tables carry it:
create table(:automation_nodes, primary_key: false) do
add :id, :binary_id, primary_key: true
add :automation_id, references(:automations, ...), null: false
add :kind, :string, null: false # "trigger" | "condition" | "action"
add :type, :string, null: false # e.g. "trigger.new_entry"
add :config, :map
add :position_x, :integer, default: 0
add :position_y, :integer, default: 0
add :label, :string
timestamps(type: :utc_datetime)
end
An AutomationNode is one card on the canvas. An AutomationEdge is a directed connection between two of them, carrying a branch label ("default", or "true"/"false" for condition splits). The edge table has a unique_index(:automation_edges, [:from_node_id, :to_node_id, :branch]) so you cannot draw the same wire twice, and every foreign key is on_delete: :delete_all — delete a node and its edges vanish with it at the database level. The context leans on that rather than reimplementing cascade in Elixir:
def delete_node(node_id) do
{count, _} =
from(n in AutomationNode, where: n.id == ^node_id)
|> Repo.delete_all()
{:ok, count}
end
kind and type do different jobs. kind is the coarse category the UI groups by; type is the specific behaviour the evaluator dispatches on. Keeping the type as a namespaced string ("trigger.entry_matches_keyword") rather than an enum means adding a new node is a data change plus an evaluator clause — no migration.
Triggers, conditions, actions
The palette catalog in the editor is the source of truth for what exists. Triggers span feed events (trigger.new_entry, trigger.entry_from_feed, trigger.entry_from_category, trigger.entry_matches_keyword, trigger.feed_error), scheduled and gamification events (trigger.scheduled, trigger.streak_milestone), and user-lifecycle events (trigger.entry_read, trigger.entry_starred, trigger.digest_sent). Actions cover the obvious surface: action.in_app_notification, action.web_push, action.email, action.webhook, action.star_entry, action.apply_tag, action.ai_summarise, action.send_to_channel. Conditions are pure predicates — condition.contains_keyword, condition.reading_time_gt, condition.is_weekend, condition.domain_matches — chained between a trigger and its actions.
Each node's config map holds its parameters, edited through a per-type form. A keyword trigger, for instance, renders exactly the fields it needs:
defp render_node_config(type, assigns)
when type in ["trigger.entry_matches_keyword", "condition.contains_keyword"] do
~H"""
<input type="text" name="keyword"
value={Map.get(@selected_node.config, "keyword", "")} ... />
...
"""
end
The engine interface: filter cheaply, evaluate precisely
When a feed event fires, the engine worker needs the candidate automations fast. The context does not try to evaluate anything in SQL — it returns any enabled automation whose graph contains a trigger node of a relevant type, and lets the graph evaluator do the precise work per automation:
def list_active_automations_for_feed(_feed_id) do
trigger_types = [
"trigger.new_entry", "trigger.entry_from_feed",
"trigger.entry_from_category", "trigger.entry_matches_keyword", ...
]
automation_ids_query =
from(n in AutomationNode,
where: n.kind == "trigger" and n.type in ^trigger_types,
select: n.automation_id
)
from(a in Automation,
where: a.enabled == true and a.id in subquery(automation_ids_query),
preload: [:user, :nodes]
)
|> Repo.all()
end
Notice the feed_id argument is deliberately ignored. A trigger.new_entry automation should fire for any feed, so filtering on feed_id in this query would wrongly exclude it. Feed-level and category-level checks belong to the per-automation evaluator, which has each node's config in hand. This is the classic coarse-filter/fine-filter split: the query trims thousands of automations to a handful with a cheap indexed subquery, and the evaluator spends real CPU only on those. Sibling queries specialise the same shape: list_active_automations_by_trigger_type/1 for the scheduler (scheduled and streak triggers), list_active_automations_for_feed_error/1 for terminal fetch failures, and list_active_automations_for_user_by_trigger/2 for user-lifecycle events routed over a separate PubSub topic.
The audit log
Every run writes one immutable AutomationExecution row — status, duration_ms, an output map, and an error_message. There is no update path; log_execution/1 only inserts. That is deliberate: an audit log you can edit is not an audit log. The executions tab in the editor pages through them newest-first, and the admin panel aggregates them for admin_stats/0 (total, active, executions and errors in the last 24h) and admin_top_action_types/2, which joins executions back to their action nodes to rank what people actually automate.
Related but distinct: feed-error handling reuses the board's ensure_ticket/3 idempotency helper so a flapping feed does not spawn a card per failed poll. It keys on source_type plus source_ref_id and returns the existing card if one is present:
def ensure_ticket(source_type, source_ref_id, attrs)
when is_binary(source_type) and is_binary(source_ref_id) do
case Repo.get_by(Ticket, source_type: source_type, source_ref_id: source_ref_id) do
%Ticket{} = existing -> {:ok, :existing, existing}
nil -> do_create_system_ticket(source_type, source_ref_id, attrs)
end
end
Idempotency at the write boundary is worth far more than trying to dedupe events upstream.
The canvas: SVG edges, HTML nodes, one JS hook
The editor is n8n-style: a palette on the left, an infinite-feeling dot-grid canvas on the right. Nodes are absolutely-positioned HTML divs; edges are SVG <path> elements in an overlay. I keep them in separate layers on purpose — nodes want rich HTML (icons, config summaries, delete buttons), edges want cheap vector paths and arrow markers.
Edges are cubic beziers computed server-side. The control-point offset scales with horizontal distance but never drops below 80px, so short connections still curve pleasantly instead of collapsing into a straight stub:
defp bezier_path(from_node, to_node) do
x1 = from_node.position_x + @node_w
y1 = from_node.position_y + div(@node_h, 2)
x2 = to_node.position_x
y2 = to_node.position_y + div(@node_h, 2)
dx = max(trunc(abs(x2 - x1) * 0.5), 80)
"M#{x1},#{y1} C#{x1 + dx},#{y1} #{x2 - dx},#{y2} #{x2},#{y2}"
end
Dragging is the one place where a round-trip per mouse-move would be absurd, so a colocated JS hook (CanvasDrag) owns the gesture. It moves the node div locally and recomputes affected edge paths in JS using the same bezier formula, then pushes a single node_moved event on pointer-up to persist the final position. The hook has two hard-won comments worth calling out: it never calls setPointerCapture() (it redirects the follow-up click and breaks phx-click selection) and it uses a 4px movement threshold so a jittery click is not mistaken for a drag. Getting drag and click to coexist on the same element was most of the work.
One nice consequence of storing positions is auto_layout_nodes/2: automations created programmatically arrive with every node at (0, 0), so on first open the editor topologically sorts the graph and lays it out left-to-right. After that, user positions win.
What I would flag to the next person
The type strings are load-bearing across three layers — palette, engine queries, evaluator — and nothing but tests keeps them in sync. That is the price of avoiding an enum, and I would pay it again; adding trigger.entry_has_image was a one-line palette entry plus one query addition. The bigger lurking cost is that the bezier math is duplicated in Elixir and JavaScript. They agree today because the constants (@node_w, @node_h) are mirrored by hand. If I touch node geometry, that is the first thing that will drift.