-
-
Notifications
You must be signed in to change notification settings - Fork 0
Write an automation node
An automation is a graph of nodes that Radd walks when a trigger fires. This page covers the node kinds, how a module registers a new type, how a run applies its plan, and how to test one.
Earlier automations were a linear pipeline — trigger, then one event condition, then one SLQ condition, then a flat list of actions. That model could not attach different actions to different branches of a condition, so it was rebuilt as a graph (spec 116). The graph has five node kinds:
| Kind | Reads | Ports | Does |
|---|---|---|---|
| Trigger | — | out |
Starts the graph. Emits the event facts and the initial item set. A graph may hold several. |
| Source | the packet | out |
The only kind that produces items — runs an SLQ query and emits what it finds. |
| Filter | each item's current state |
matched, unmatched
|
A predicate over items. Narrows the set; both ports always emit, because an empty subset is a real answer. |
| Gate | the event facts |
true, false (or type-specific) |
A boolean over the event — who acted, which fields changed. The item set passes through untouched. Only the port taken emits. |
| Action | the packet |
out (+ created for some) |
Does work, then passes its input through unchanged so chains continue. |
A "condition" in the older, simpler sense is really two different questions, and the model keeps them as two kinds on purpose:
…"Was this changed by a member of the QA team" is not a property any single item has — it is a property of the event. Modelling both as one "condition" node produces a node whose ports mean different things depending on what you configured, which is unteachable and untypeable.
The payoff for splitting them is that a per-item if/else needs no third concept: it is a Filter with both ports wired.
Filter(priority = high) ──matched──→ Action(notify lead)
──unmatched→ Action(add label "routine")
— docs/specs/116-automation-graphs.md ("Why Filter and Gate are different nodes")
What flows on an edge is a packet: (event facts, item ids per subject),
never items alone — actions template {{tokens}} off the event and gates
evaluate on it. A packet actually carries ids per entity type, not only
items:
@dataclass(frozen=True)
class Packet:
"""What travels along an edge.
IDS, never rows — the engine loads them, and a packet carrying ORM objects
would tie this module to a session. …
**Subjects, plural (RADD-923).** A packet carries ids per ENTITY TYPE, not
just items: an event about a deployment can name the deployment, the
release and the item at once, and a contributed action node declaring
`subject="deployment"` is handed exactly those ids. …
"""
facts: Any # conditions.EventFacts — typed there, kept opaque to stay pure
subjects: Mapping[str, tuple[uuid.UUID, ...]] = field(default_factory=dict)— server/src/radd/modules/automations/graph.py
How often a node runs is a second, independent axis — its arity (RADD-918), not implied by its kind:
class NodeArity(StrEnum):
"""How a node reads its input packet — the axis that used to be implied by
the action type and could not be chosen (RADD-918).
… In a dataflow model over SETS, "for each" is not control flow — it is
how a node reads its input, and that is what this names.
* **SET** — the node runs once and sees the whole packet. A router sends
the packet down ONE port; an action fires once.
* **ITEM** — the node runs once per item. A router PARTITIONS the set across
its ports (each item leaves by the port its own answer names); an action
fires once per item.
"""
SET = "set"
ITEM = "item"— server/src/radd/modules/automations/types.py
A gate is fixed at SET (it reads EventFacts, where per-item would be N
identical answers). A filter is fixed at ITEM. Ten of the built-in item
mutations (set_state, add_label, …) are fixed at ITEM. Five actions —
create_item, send_email, send_webhook, post_chat, notify_user —
and the AI classifier below let the author choose either, because both
readings are real ("one triage ticket" vs. "a follow-up per matched item").
Built-in node types are not registered through the kernel at all. They
are hardcoded in automations' own executor.py, gates.py and types.py
— trigger.event, filter.slq, search.slq, gate.field_changed,
gate.changed_by, gate.state_category, and every action.<ActionType>.
This predates the registry and stays that way.
A node type contributed by another module — a plugin declaring "when my
deployment finishes" or "ask the AI" — goes through a kernel spec, the same
declare-once pattern as PageExtensionSpec:
@dataclass(frozen=True)
class AutomationNodeSpec:
# (docstring omitted here — the fields below carry per-field comments in
# the real file; see server/src/radd/kernel/specs.py)
key: str # "filter.slq", "action.create_item", "acme.notify_oncall"
kind: str # AutomationNodeKind value — fixes whether it filters, gates or acts
label: str
description: str = ""
group: str = "Other" # palette section
params_schema: dict[str, Any] = field(default_factory=dict)
ports_for: Callable[[Mapping[str, Any]], tuple[str, ...]] = lambda _params: ("out",)
needs_items: bool = True
arity: str = "set"
arity_options: tuple[str, ...] = ()
permission: str = ""
subject: str = "item"
plan: Callable[..., Any] | None = None
apply: Callable[..., Any] | None = None
plan_items: Callable[..., Any] | None = None— server/src/radd/kernel/specs.py
A module lists its nodes on its RaddPlugin manifest:
automation_nodes=(ai_automation_node.SPEC,),— server/src/radd/modules/ai/init.py
At run time, every place that needs to know a node's ports, arity or handler checks the kernel registry first, falling back to the built-in tables second — so a graph mixing contributed and built-in nodes resolves the same way either path was taken:
def ports_of(node: graph.Node) -> tuple[str, ...]:
spec = registries.automation_nodes.get(node.type)
if spec is not None:
return tuple(spec.ports_for(node.params))
builtin = BUILTIN_PORTS.get(node.type)
if builtin is not None:
return tuple(port.value for port in builtin)
return graph.default_ports(node)— server/src/radd/modules/automations/nodes.py
Disabling the plugin removes its entries from registries.automation_nodes
in the same step as every other kernel contribution (the spec-94 unmount
path), so its node type leaves the palette immediately. A stored graph that
still holds one of its nodes does not crash — ports_of/arity_rule answer
a safe fallback (("out",), arity fixed at SET) for a type nobody knows,
and the executor logs and skips the node rather than raising.
A node contributes a plan, never an apply-only handler, and the split
is the safety property, not a style choice:
#: `apply(ctx, plan)` — an ACTION node's other half (RADD-923).
#:
#: `plan` runs on every walk including a dry run, which is what makes the
#: report free and identical to the real thing; `apply` runs only when
#: applying, inside the executor's SAVEPOINT, inside its `RunBudget`, and
#: inside the `events.automated()` scope. A contributed action therefore
#: cannot spin the engine, cannot escape the budget, and cannot take the
#: branch down when it raises.
apply: Callable[..., Any] | None = None— server/src/radd/kernel/specs.py
A gate or filter implements plan (SET arity) or plan_items (a
per-item partition, when the node's own batching or concurrency matters —
otherwise the executor falls back to calling plan once per single-item
packet, which is correct and is the whole feature for a cheap node). An
action implements plan and apply: plan decides what it would do and
is read-only; apply does it.
A node's own permission field is additional to the baseline: creating,
editing or viewing any automation at all requires automation.manage
(global-scoped, held by instance administrators only — rules act on items
and run as a system-capable actor, so managing them is admin-level).
permission narrows further, gating one specific node type — checked at
write time, the same pattern act_as uses below, so a caller without the
atom never gets an automation that saves and then silently refuses to run.
What fires a rule. Three entry points, all funnelled through one function so branching semantics live in exactly one place:
async def run_graph(
session: AsyncSession, rule, initial: Packet, system_user: User, *,
apply: bool = True, start_node_id: str | None = None,
) -> executor.RunReport | None:
"""Execute one automation's graph over an initial packet.
Every entry point funnels through here — event, schedule and manual — so the
branching semantics are defined once. Returns None when the stored graph will
not load, which is a data problem to log rather than an exception to escape
into the consumer loop and stall the cursor.
"""— server/src/radd/modules/automations/engine.py
-
Event. The automation engine is an outbox consumer, polling every
settings.automation_poll_intervalseconds (default 1.0) in batches ofsettings.automation_batch(default 100).should_processgates on the event being in the live trigger catalog, notsilent(a bulk import), and not itself caused by an earlier automation (the loop guard, below). -
Schedule.
automation.scheduled— a synthetic outbox event a scheduler emits per due trigger node (spec 69) — carriesrule_idandnode_id; the trigger's ownqueryparam supplies the initial item set. -
Manual.
POST /automations/{id}/runor the editor's/quick-action menu, one item at a time, starting at a MANUAL trigger node.
What context a node receives — deliberately small:
@dataclass
class _NodeContext:
"""What a contributed node's planner is handed. Deliberately small: a
session for its own reads, the node it is, and the packet — not the
whole engine."""
session: AsyncSession
node: Node
packet: Packet
#: Who the automation runs as. A contributed node reads THROUGH this, so
#: its prompt can only contain what that identity could already see, and
#: an action applies with exactly that identity's rights.
actor: User
#: The ids of the node's DECLARED subject this invocation is for
#: (RADD-923): one id per call at item arity, the whole set at set arity.
subject_ids: tuple[uuid.UUID, ...] = ()— server/src/radd/modules/automations/executor.py
How a condition short-circuits. A GATE at SET arity sends the packet down exactly one port — the other port emits nothing at all, so nothing wired to it runs:
async def _run_router(
session: AsyncSession, node: Node, packet: Packet, actor: User
) -> dict[str, Packet]:
"""* **SET arity — ROUTE.** One answer for the whole packet, which leaves by
one port. The other ports emit NOTHING, so the branches not taken do not run.
* **ITEM arity — PARTITION.** Each item leaves by the port its own answer
names. Every port emits, including the empty ones …
"""
ports = ports_of(node)
if arity_of(node) is NodeArity.SET:
chosen = await _route(session, node, packet, actor)
# …
return {chosen: packet}
assigned = await _partition(session, node, packet, actor)
return {
port: packet.with_items([i for i in packet.item_ids if assigned.get(i) == port])
for port in ports
}— server/src/radd/modules/automations/executor.py
This distinction matters downstream: a gate's untaken branch is absent
from the run report (it never ran), while a filter's unmatched port with
zero items is a real, taken answer that happened to match nothing. Before
this was fixed (RADD-918), a gate emitted an empty packet on the port it
did not take, so gate → false → "nobody touched it" fired that message on
every run where somebody had — because a universal action ignores
emptiness by design and ran anyway.
How actions run in order. The graph is validated as a DAG on write
(cycles are rejected then, not at run time), and nodes execute in
topological order — ties break on node id, so two runs over the same graph
apply their side effects in the same sequence. Each action still runs
inside its own SAVEPOINT, so one failing action rolls back only itself.
Budgets. A linear rule cost items × actions; a graph costs
items × nodes × fan-out. A per-run RunBudget caps node executions
(automation_graph_max_node_runs, default 200) and item-actions
(automation_graph_max_item_actions, default 2000). A truncated run
records what it dropped rather than silently doing less — a run that
did less must not look identical to a run that had less to do.
Every event type a plugin marks triggerable appears live in the trigger catalog — nothing is hardcoded per event:
def triggers() -> dict[str, EventTypeSpec]:
"""The automation trigger catalog — every registered event type marked as a
trigger, read live from the kernel registry (chokepoint-1 inversion). Each
spec carries `event_type`/`label`/`group`/`item_scoped`/`has_changes`, the
shape the old `TriggerSpec` had, so consumers are unchanged."""
return registries.triggers()— server/src/radd/modules/automations/catalog.py
A trigger.event node's params name one event type: {"event": "item.created"}. What the run sees is EventFacts:
@dataclass(frozen=True)
class EventFacts:
"""Plain-data view of one event, prepared by the engine for evaluation."""
event_type: str
actor_id: str | None
actor_email: str | None
actor_name: str | None
payload: dict[str, Any] = field(default_factory=dict)
@property
def changes(self) -> list[dict[str, Any]]:
"""item.updated field diff: [{"field", "from", "to"}, ...]; [] elsewhere."""
raw = self.payload.get("changes")
return raw if isinstance(raw, list) else []— server/src/radd/modules/automations/conditions.py
A condition never has to guess a payload's shape by reading twenty modules'
emit calls. GET /automations/samples/events flattens real, recent
events into dotted paths with the values actually seen, because a
hand-authored example is a second copy of a shape that drifts silently — an
event type that has never fired reports so, rather than fabricating one.
Universal actions (create_item, send_webhook, post_chat,
notify_user, send_email) template their params with {{token}}. The
catalogue and the resolver sit side by side deliberately, so a documented
token and a working one never drift apart:
TOKENS: tuple[TokenInfo, ...] = (
TokenInfo("{{event_type}}", "The event that fired, e.g. item.updated."),
TokenInfo("{{actor.name}}", "Who caused the event."),
TokenInfo("{{item.key}}", "The target item's key, e.g. TD-42.", needs_item=True),
TokenInfo("{{items.count}}", "How many items this action is acting on."),
TokenInfo("{{items.keys}}", "Their keys, comma-separated — TD-42, TD-43."),
TokenInfo("{{items.list}}", "One per line: `TD-42 — the title`. For chat and email bodies."),
TokenInfo(
"{{matched_count}}",
"How many items a scheduled run matched. Kept for automations written "
"before {{items.count}}, which is the same number under any trigger.",
),
TokenInfo(
"{{payload.<path>}}",
"Anything from the raw event payload, by dotted path — e.g. "
"{{payload.changes.field}}. Lists join with commas.",
),
)— server/src/radd/modules/automations/templating.py
{{item.*}} resolves against the single item a per-item action is about.
The {{items.*}} tokens are set-shaped and resolve against whatever an
action speaks for at its own arity — the whole packet once, or the one item
it was invoked for — so the same template text reads correctly under
either mode. An unresolvable token renders verbatim, {{like this}},
rather than raising or vanishing — visible in the output, and debuggable.
An automation's actions run as its author by default — a real person's
identity, not a system account, which is what makes {{actor.name}} and
attributed writes work without extra parameters. A node may name someone
else with act_as, gated by a permission checked where the graph is
written, not at run time:
# `act_as` is a privilege, checked where it is WRITTEN. Checking it at run
# time instead would mean an automation that saves cleanly and then quietly
# refuses at 3am, and the field is hidden in the UI for anyone without the
# atom — a hidden field that the API still accepts is not a permission.
acting_as = {
str(node.params.get("act_as") or "").strip()
for node in parsed_nodes
if node.kind is AutomationNodeKind.ACTION and node.params.get("act_as")
}
if acting_as and actor_id is not None:
author = await session.get(User, actor_id)
if author is not None:
await authz.require(session, author, Permission.AUTOMATION_ACT_AS)— server/src/radd/modules/automations/service.py
At run time, the executor resolves the named account, falling back to the automation's author (never escalating to a system actor) if the name no longer resolves:
async def _actor_for(session: AsyncSession, node: Node, default: User) -> User:
"""Who this action runs as.
`act_as` on the node names a user by email; absent, the automation's
author (passed in as `default`). Falls back to the default when the
named account no longer resolves — an automation must not stop working
because someone left, and it must not silently escalate either, which is
why it falls back to the author rather than to the system actor.
"""— server/src/radd/modules/automations/executor.py
The loop guard is identity-independent. Every event an applied action
emits is marked automated, whoever it ran as — which is what makes
act_as safe: identity moved, causation did not, so an action cannot
retrigger its own rule by acting as someone else.
def is_automation_caused(event: Event) -> bool:
"""True if this event was emitted by an engine-applied mutation (the loop guard).
Reads the event's own `automated` marker, not its actor. Since spec 116 an
action may run AS a real person, so "the actor is the system user" no
longer answers "did an automation cause this" — and inferring it from
identity would let any act-as automation re-trigger itself forever.
The actor check stays as a second arm: the scheduler and older rows predate
the marker, and an event with the system actor is automation-caused either
way."""
return bool(getattr(event, "automated", False)) or event.actor_id == SYSTEM_ACTOR_ID— server/src/radd/modules/automations/planning.py
Rows written before spec 116 carry no author and keep running as
SYSTEM_ACTOR_ID (automation@radd.system) — exactly what they always did.
Best-effort, per node. Each action runs inside session.begin_nested();
an exception is caught, logged with logger.exception, and the walk
continues to the next node — one bad webhook URL does not stop a graph's
other branches. Nothing is re-raised into the consumer loop.
A target that no longer resolves is a SKIP plan, not an exception.
Planning is pure and read-only, and returns a _Plan either way:
case ActionType.SET_STATE:
name = params["state"]
state = await _state_by_name(session, project.id, name)
if state is None:
return _Plan(PlanKind.SKIP, f"set_state: no state {name!r} in {project.key}")
return _Plan(PlanKind.ITEM_UPDATE, f"set_state -> {name!r}", ItemUpdate(state_id=state.id))— server/src/radd/modules/automations/planning.py
Because plan always runs — even in a dry run, even before apply is
checked — POST /automations/{id}/test shows exactly this outcome ahead of
time: resolves: false and the human-readable detail ("no state 'Foo' in
TD") are what an author sees before ever enabling the rule.
Budget truncation is reported, not silent. RunBudget.dropped names
every node or item-batch a cap cut off, and a truncated run logs it — a run
that did less must read differently from a run that had nothing to do.
A gate's untaken branch and a filter's zero-item branch read differently
in the test panel (nodes[].ports[].taken), for the reason given above: one
never ran, the other ran and found nothing.
The exact node/edge JSON a real automation stores — a branching graph with
one of each kind that ships without a plugin, proven end to end by
automations-canvas-proof.mjs:
{
"name": "canvas proof (branching)",
"enabled": false,
"nodes": [
{ "id": "trigger", "kind": "trigger", "type": "trigger.event", "params": { "event": "item.updated" } },
{ "id": "f", "kind": "filter", "type": "filter.slq", "params": { "slq": "priority = high" } },
{ "id": "hot", "kind": "action", "type": "action.add_label", "params": { "label": "urgent" } },
{ "id": "cold", "kind": "action", "type": "action.add_comment", "params": { "body": "routine" } }
],
"edges": [
{ "source": "trigger", "port": "out", "target": "f" },
{ "source": "f", "port": "matched", "target": "hot" },
{ "source": "f", "port": "unmatched", "target": "cold" }
]
}— web/scripts/automations-canvas-proof.mjs
-
Trigger —
trigger.event, params{"event": "item.updated"}: any item update starts this graph. -
Condition —
filter.slq, params{"slq": "priority = high"}: a Filter kind, ITEM arity, partitioning the set acrossmatched/unmatched. (A Gate example:gate.field_changedtests the event's own diff, not the item's current state — seeconditions.py'sfield_changedevaluator, cited above under "The model".) -
Action —
action.add_label, params{"label": "urgent"}on the matched branch;action.add_commenton the unmatched branch. Both are fixed at ITEM arity (ACTION_ARITY_DEFAULT), so each fires once per item that reached it.
A plugin-contributed node, all three concerns in one spec — the AI module's classifier, a GATE that routes by an enumerated model answer instead of a fixed boolean:
SPEC = AutomationNodeSpec(
key=NODE_KEY, # = "ai.classify"
kind="gate", # routes the packet without changing the item set
label="Ask the AI",
description=(
"Ask a question about the items and route them by the answer. Answers are "
"enumerated, so the model cannot invent a branch that does not exist. Per "
"item, each issue leaves by its own answer's port."
),
group="Gates",
params_schema=PARAMS_SCHEMA,
ports_for=ports_for,
needs_items=False, # "nothing matched — is that a problem?" is a fair question
# Default SET: it is the cheap mode, and defaulting to one model call per
# item would make dropping this node on a scheduled run over a broad query
# an expensive accident.
arity="set",
arity_options=("set", "item"),
plan=plan,
plan_items=plan_items,
)— server/src/radd/modules/ai/automation_node.py
PARAMS_SCHEMA requires prompt and 2–8 answers; ports_for returns one
port per answer the author typed into the form, plus a fixed unavailable
fallback for a provider outage — which is why ports had to become a
property of the node type, not of its kind.
Pure logic — graph.py (DAG validation, topological order),
conditions.py (the event-condition tree), gates.py (the three named
gate evaluators) and templating.py ({{token}} resolution) take no
session and do no I/O, so they run under plain pytest:
server/tests/test_automation_graph.py, test_automation_conditions.py,
test_automation_arity.py and test_automations.py. Run the whole suite
with uv run pytest from server/.
The editor — seven browser proofs under web/scripts/, each driving a
real headless Chromium session:
| Script | Proves |
|---|---|
automations-graph-proof.mjs |
The settings page still renders after the linear-pipeline-to-graph migration; a linear rule and a branching one both round-trip through the API with their nodes and ports intact. |
automations-editor-proof.mjs |
The editor is the canvas: the node panel lists every category and searches; several triggers can coexist; right-click opens a node menu; orientation flips vertical/horizontal with the ports. |
automations-canvas-proof.mjs |
The canvas actually draws a branching graph — measured node boxes at distinct positions, edge paths with real geometry and a visible (non-black) stroke, the right handle count. |
automations-conditions-proof.mjs |
The palette offers named condition nodes (Field changed / Changed by / State category), not the old abstract event-condition tree; the AI classifier's ports redraw live as its answers are edited; Act as appears only for a caller holding automation.act_as. |
automations-arity-proof.mjs |
The Run (once/per item) control is server-derived, appears only on configurable actions, and a role recipient on send_email locks it to per item. |
automations-delete-proof.mjs |
A node deleted by keyboard (React Flow's own delete key) or by the inspector's Delete button stays deleted — including across a save and reload. |
automations-visibility-proof.mjs |
The trigger inspector's sampled event paths are clickable into {{payload.…}} tokens; a dry run reports exact per-node, per-port counts and samples; a gate's untaken branch reads "not taken", not "0". |
Run one against a local dev server:
node scripts/automations-graph-proof.mjs --base http://localhost:8000
The node search panel groups every offered type by its group — for a
trigger this is EventTypeSpec.group, a free-form category label the
declaring module chooses ("Cycles", "Service desk", …), not the module's
name; SOURCES, FILTERS and GATES list the built-ins plus whatever a
loaded plugin (like ai.classify, under Gates, via AutomationNodeSpec.group)
contributed.
-
Write a backend plugin — the
RaddPluginmanifest and kernel registriesautomation_nodesbuilds on. -
Events and consumers — how a module declares an event type as
triggerable, and what
events.automated()does for the loop guard. -
The query language for developers — SLQ, which
filter.slqandsearch.slqboth compile through.
Mirrored from project.radd-hq.com on 2026-09-12. Documentation is written there; this copy is regenerated by scripts/publish_wiki.py and hand edits do not survive it.
-
Developer guide
- Architecture: the kernel and plugins
- Develop, test and deploy
- Events and consumers
- Permissions and access control
- The MCP server
- The query language for developers
- The REST API and authentication
- Write a backend plugin
- Write a page editor extension
- Write a plugin user interface
- Write an automation node
-
Release notes
- 0.36.4
- 0.36.3
- 0.36.2
- 0.36.1
- 0.36.0
- 0.35.0
- 0.34.0
- 0.33.0
- 0.32.0
- 0.31.1
- 0.31.0
- 0.30.0
- 0.29.0
- 0.28.0
- 0.27.0
- 0.26.0
- 0.25.1
- 0.25.0
- 0.24.1
- 0.24.0
- 0.23.1
- 0.23.0
- 0.22.0
- 0.21.0
- 0.20.0
- 0.19.0
- 0.18.1
- 0.18.0
- 0.17.2
- 0.17.1
- 0.17.0
- 0.16.0
- 0.15.0
- 0.14.1
- 0.14.0
- 0.13.1
- 0.13.0
- 0.12.0
- 0.11.0
- 0.10.0
- 0.9.2
- 0.9.1
- 0.9.0
- 0.8.1
- 0.8.0
- 0.7.1
- 0.7.0
- 0.6.6
- 0.6.5
- 0.6.4
- 0.6.3
- 0.6.2
- 0.6.1
- 0.6.0
- 0.5.0
- 0.4.1
- 0.4.0
- 0.3.2
- 0.3.0
- 0.2.0
- 0.1.0
-
User guide
- AI features
- Attachments
- Automations
- Cycles and releases
- Instance settings
- Intake forms and the portal
- Notifications and the inbox
- Personal settings
- Project settings
- Projects
- Reports and dashboards
- Search and the query language
- Start here
- The application window
- The card designer
- The roadmap
- The service desk
- The wiki
- Time logging and the timesheet
- Views
- Work items