When I started building serious automations, Home Assistant’s native automation system wasn’t where I needed it to be. The tooling was limited, YAML-heavy, and hard to reason about for anything beyond simple triggers. Node-RED had emerged as the power user’s choice in the community, and it was immediately obvious why.
Why Node-RED
The thing that drew me in wasn’t the JavaScript support, though that turned out to matter, it was the visual model. Automations are pipelines: something changes, you evaluate it, you decide what to do, you do it. Node-RED represents that as a flowchart, and that maps almost perfectly to how I think about automation logic. I could wire up a new automation in minutes, see exactly how it connected to other flows, and iterate quickly. For getting a system off the ground, it was exactly what I was looking for.
The JS function nodes were a bonus. For simple things I never needed them. But when logic got complex, I could switch to writing code without leaving the tool. It felt like having the best of both worlds.
How the runtime developed
As my automations matured, I started treating them more like production software. Reliability and observability weren’t nice-to-haves, they were the point. That pushed me towards what I ended up calling the “runtime”, a set of reusable subflows that every automation would pass through.
The pattern that emerged was that every trigger flows through a guard and correlation step (is this event worth acting on? give it an ID), then a context-loading step (what does the room currently know?), then a pure reducer function (given everything we know, what should happen?), then a fanout step (publish the decision to observability, execute the actions, handle loopback timers). The reducer function emits a list of actions, it never calls a service directly. The runtime executes the plan.
This is good architecture. The reducer is pure, in the sense that it has no side effects and given the same inputs will always produce the same actions. Every decision gets a correlation ID and is published to MQTT as a structured snapshot of what the system knew, what it decided, and why. I can query that log and understand what happened at any time without recreating conditions. The contracts between steps are enforced by the guard nodes, and everything is consistent.
Node-RED made this possible. Subflows let me package each step as a reusable component, and wiring them together was quick. The occupancy controller, for example, handles motion events, “strong holds” (a media player playing, a switch held on), door contacts for containment logic, timer loopbacks, and a failsafe timeout. It’s a genuinely complex piece of behaviour, and the runtime made it traceable.
In principle, a pure reducer with no side effects should be trivially testable in isolation. In practice, that runs into Node-RED’s limits immediately. There is no way to write a unit test for a function node. To verify the occupancy reducer handles containment correctly, I wire up an inject node, fabricate a message that looks like the right state, deploy, trigger it, and inspect a debug node. That’s not a test, it’s a manual check, and it doesn’t survive a refactor.
Where it started to strain
The occupancy controller is a good example for a different reason too. It’s worth looking at what it actually contains: a pipeline of subflows (guard, correlate, discover hold entities, reduce, guard decision contract, fanout, action runtime) and then the reducer function, which is where the actual occupancy logic lives. The evidence model, the containment logic, the decision tree, the timer behaviour. That’s maybe a third of what’s in the flow, the rest is pipeline.
The subflow solved the room problem. I can drop the occupancy controller subflow into any room tab and it works. What it didn’t solve is the cross-domain problem. The lighting controller has the same pipeline. The heating controller has the same pipeline. Essentially every flow has the same pipeline.
Each time I start a new automation domain, I’m rebuilding the same scaffold: create the subflow, wire up the steps, configure the environment variables, check that the contracts are passing correctly. The interesting part, the actual logic of the domain, is a function node sitting in the middle of all that. And as the reducers got more complex, with containment logic and multi-signal evidence models and timer interactions, the inability to test them properly became a real cost. If I changed the occupancy reducer, the only way to know I hadn’t broken the containment edge case was to physically go and close the bedroom door.
When the core of every automation is a function with defined inputs and outputs, and everything around it is scaffolding I wire up manually each time, you haven’t outgrown the logic. You’ve outgrown the host. The flow editor was no longer the abstraction. It was the overhead.
What the visual model is actually good for
This isn’t a criticism of Node-RED or visual programming in general. The flowchart model maps well to automation logic, and Node-RED is genuinely good at exposing it. I outgrew it, specifically. The point at which the interesting logic moved entirely into function nodes, and the visual layer became a way of wiring those nodes to a fixed scaffold, is the point at which the visualisation became a nice-to-have rather than the core value. What I actually wanted was a way to write the reducer and have a framework enforce everything else.
What I actually want
The pattern the runtime established is good. Guard, correlate, load context, reduce, fanout, execute. Enforce that every automation follows it. Make observability automatic. Make correlation IDs automatic. Make the action contract enforced by types rather than by a guard node that can only catch failures at runtime. And make the reducer a function I can actually test: give it inputs, assert on the actions it returns, without a live Home Assistant instance, without deploying anything, without closing any doors.
What I want is to write this:
reduce(inputs: OccupancyInputs): Action[] {
if (!inputs.motionEnabled) return [noChange("motion_disabled")];
if (inputs.motionActive || inputs.strongHoldActive) return [setOccupied()];
if (inputs.isTimerExpired) return [clearOccupied()];
return [startClearTimer(inputs.clearDelayMs)];
}
And have the framework handle everything around it: the trigger wiring, the context loading, the observability publishing, the action execution, the loopback timers. The reducer is the automation. The rest is infrastructure.
That’s what I’m building. More on that separately.