Typed, testable Home Assistant automations in TypeScript.
Status: pre-1.0. The API may change. Builds are published to npm as
@ajclarkson/homerun(edgetag, on every push tomain) and as a Docker image,ghcr.io/ajclarkson/homerun:edge.
Homerun is a framework that replaces HA automations and Node-RED flows with pure functions. Every automation is a context builder and a reducer — no side effects, no hidden state, no YAML. The pipeline handles observability, error isolation, and action execution.
Home Assistant automations are YAML. Node-RED flows are JSON blobs. Both are hard to test, hard to review, and impossible to type-check. A bad deploy silently breaks your lights at 2am.
Homerun brings the same discipline you'd apply to application code: pure functions, typed inputs, and a test suite that runs in milliseconds.
Every automation follows the same pipeline:
Event → context() → reduce() → actions[]
context() reads from the HA state cache and returns a typed snapshot — or aborts early if a required input is missing. It is the only place that touches external state.
reduce() is a pure function. It receives the context and returns a decision with a list of declarative actions. No async, no side effects, no HA calls.
The pipeline handles everything else: correlation IDs, observability events, error isolation, and action execution.
import { defineAutomation, abort, requireState, requireNumericState } from '@ajclarkson/homerun';
export default defineAutomation({
id: 'kitchen:lighting',
location: 'kitchen',
subsystem: 'lighting',
triggers: [
{ type: 'state_changed', entity: 'binary_sensor.kitchen_motion' },
{ type: 'on_start' },
],
context(state) {
if (requireState(state, 'input_boolean.kitchen_lights_enabled') !== 'on') {
return abort('automation_disabled');
}
return {
motion: requireState(state, 'binary_sensor.kitchen_motion') === 'on',
lux: requireNumericState(state, 'sensor.kitchen_lux'),
luxThreshold: requireNumericState(state, 'input_number.kitchen_lux_threshold'),
};
},
reduce(ctx) {
const shouldLight = ctx.motion && ctx.lux < ctx.luxThreshold;
return {
decision: shouldLight ? 'lights_on' : 'lights_off',
actions: [
{
type: 'ha.call_service',
domain: 'light',
service: shouldLight ? 'turn_on' : 'turn_off',
target: { entity_id: 'light.kitchen_ceiling' },
},
],
};
},
});requireState and requireNumericState abort the run, with the missing entity recorded, if an entity is unavailable or non-numeric, so a dead sensor never silently becomes a wrong decision. The decision's conditions default to the full context, so the published event explains itself.
Files like this go in your automations directory; see Consumer setup.
Because reduce() is a pure function, tests are just function calls. To test the whole automation without a Home Assistant connection, use the test helpers:
import { describe, it, expect } from 'vitest';
import { testAutomation } from '@ajclarkson/homerun/testing';
import kitchenLights from './kitchen-lights';
describe('kitchen lighting', () => {
it('turns lights on when motion is detected below the lux threshold', () => {
const result = testAutomation(kitchenLights, {
event: { type: 'state_changed', entity_id: 'binary_sensor.kitchen_motion', new_state: { state: 'on' } },
state: {
'input_boolean.kitchen_lights_enabled': { state: 'on' },
'binary_sensor.kitchen_motion': { state: 'on' },
'sensor.kitchen_lux': { state: '20' },
'input_number.kitchen_lux_threshold': { state: '40' },
},
});
expect(result.decision).toBe('lights_on');
expect(result.actions[0]).toMatchObject({ service: 'turn_on' });
});
});No mocking, no HA connection, no async. testAbort and testUnavailable cover the abort paths. See Automation API.
| Trigger | Fires when |
|---|---|
state_changed |
An entity's state or attributes change. Filter with to; delay with duration. |
schedule |
A cron expression fires. |
on_start |
The system is ready and the state cache is fully populated. |
timer_expired |
A named timer set by a previous timer.start action expires. |
button |
A button entity emits a single_press, double_press, or hold gesture. |
mqtt_in |
A message arrives on a subscribed MQTT topic. |
| Action | Effect |
|---|---|
ha.call_service |
Calls a Home Assistant service |
mqtt.publish |
Publishes to an MQTT topic |
timer.start |
Starts (or restarts) a named timer |
timer.cancel |
Cancels a named timer |
Actions are returned from reduce() as a plain array; the runtime executes them after the decision event is published. Full types and semantics: Automation API.
Every pipeline run publishes an event to MQTT — whether it decided, aborted, or threw — and every action publishes its own start and result events. A correlation_id minted at the event source lets you trace a HA state change through to the service call that resulted from it.
{
"schema": "home.events.v2",
"correlation_id": "b3d2f1a0-...",
"root_correlation_id": "b3d2f1a0-...",
"automation_id": "kitchen:lighting",
"location": "kitchen",
"subsystem": "lighting",
"event_type": "decision",
"trigger": { "type": "state_changed", "entity_id": "binary_sensor.kitchen_motion", "to": "on" },
"decision": "lights_on",
"conditions": { "motion": true, "lux": 20, "luxThreshold": 40 },
"actions": [{ "type": "ha.call_service", "domain": "light", "service": "turn_on" }],
"hasAction": true,
"timestamp": "2026-06-23T18:00:00.000Z"
}Schema, topics, command-ack tracking and Prometheus metrics: Observability.
By default a decision's reason is a free string. An automation can instead declare the reasons it may give, so a consumer (a dashboard, a notifier) can explain what happened without parsing strings. Everything here is opt-in: an automation without reasons behaves and publishes exactly as before.
export default defineAutomation({
id: 'kitchen:lighting',
location: 'kitchen',
subsystem: 'lighting',
triggers: [{ type: 'state_changed', entity: 'binary_sensor.kitchen_motion' }],
reasons: {
lights_on: { outcome: 'acted' },
lux_high: { outcome: 'declined', facts: ['lux', 'threshold'] },
startup: { outcome: 'ignored' },
},
context: (state) => ({ lux: requireNumericState(state, 'sensor.kitchen_lux') }),
reduce: ({ lux }) =>
lux > 40
? { decision: 'no_action', reason: 'lux_high', facts: { lux, threshold: 40 }, actions: [] }
: { decision: 'turn_on', reason: 'lights_on', actions: [kitchenLightsOn] },
});outcomeisacted(the rule changed something),declined(it chose not to act and a person would want to know why) orignored(noise: startup, already in the right state). It is declared, never inferred fromactions.factsare the typed values a sentence needs: at most 8 keys per reason, each a string, finite number, boolean ornull. They are separate fromconditionson purpose:conditionsis a debug dump of the whole context that may change shape freely,factsis the small, named, checked set a consumer can depend on.- Declared reasons are enforced three times. At compile time
reasonis the union of declared keys andfactsmust carry exactly that reason's keys (an extra key is only caught at run time, as TypeScript does not check excess properties on returned objects). At load timedefineAutomationthrows on a bad outcome, fact list or reason name (letters, digits and underscores only: reasons carry no free text). At run time an undeclared reason, or an undeclared, missing or non-primitive fact, never stops the actions: the decision is published withoutoutcomeorfacts(the undeclared shape) and a separatedeclaration_errorevent is published tohomerun/eventsand logged, withproblem(undeclared_reason,missing_fact,undeclared_factorinvalid_fact_value) and, when identifier-shaped,reasonandfact. Observability must never prevent a real action. Automations that declare nothing cannot returnfacts(a compile error).defineAutomationand the registry (including hot reload) both reject a bad declaration, and the reserved locationdev, at load. - The decision event gains optional
outcomeandfacts, only for declaring automations.
Catalogue. The declarations are published retained on homerun/catalog (home.catalog.v1) at startup and after a reload, and each entry's reasons is added to GET /automations. Only automations that declare reasons appear. The catalogue is published on every startup, empty if nothing declares reasons, so a retained copy is never stale. A consumer can check its wording table against it.
History. Set history in the configuration (off by default) to keep a ring buffer of the events worth asking "what happened here?" about:
history:
size: 20 # per location, 1 to 200
houseSize: 30 # house-wide, 1 to 200Each stored entry is republished, newest first, retained on homerun/{location}/history and homerun/history (home.history.v1). Remembered: declared acted and declined decisions (or, for undeclared automations, decisions with an action), aborts other than disabled, and failed actions (a failed action_result or an action_ack_timeout). Entries are structured only: reason keys, facts, a trigger summary, and a detail record (abort_kind/entity/stage/error_type, or failure/action/entity). Retained republishes are coalesced (250ms) with the latest state always published, and flushed on shutdown. On startup the buffers are re-seeded from the retained topics, so history survives a restart; a malformed retained message is logged and that buffer starts empty. Seeding waits 1s for the broker to deliver the retained messages. dev is reserved as a location name (rejected at registration). Both the catalogue and history follow events.enabled: when it is false, neither is published (nor history seeded or kept), like the rest of the MQTT stream. In dry-run mode every one of these topics moves under homerun/dev/.
The JSON Schemas for both payloads are in schemas/ and published with the package. Every payload is validated against them before it is published.
npm install @ajclarkson/homerunHomerun reads a configuration.yaml (path from HOMERUN_CONFIG) with your Home Assistant URL and token, MQTT broker and automations directory. Secrets can live in a secrets.yaml beside it and be referenced with !secret. configuration.yaml.example shows every option.
Set options.dry_run: true to run the full pipeline — context, reduce, observability — without executing any actions.
Setting up your own automations repo, generating entity types from your Home Assistant, and running the runtime are covered in Consumer setup.
To work on the framework itself, clone this repo and use npm run dev (run from source, hot-reloads) and npm test.
The server exposes an HTTP API on port 7070 by default (server.port in the config).
| Method | Path | Description |
|---|---|---|
GET |
/health/live |
Always returns 200 { status: "live" } — used for liveness probes. |
GET |
/health/ready |
Returns 200 when the HA state cache is populated and automations are loaded, 503 while starting. Response includes entities, automations counts, and dry_run: true if running in dry-run mode. |
GET |
/automations |
Lists all registered automations with their id, location, subsystem, and trigger types, plus reasons for those that declare them. |
POST |
/automations/:id/trigger |
Manually fires an on_start event for the given automation. |
POST |
/reload |
Rescans the automations directory and hot-reloads changed files. |
GET |
/events |
Server-sent event stream of all pipeline decisions and action events in real time. |
HAClient ──────────────────────────────────────┐
└─ state_changed (per entity, with corr. ID) │
▼
Scheduler (cron / on_start) ──────► TriggerEngine.dispatch()
TimerManager (setTimeout) ──────► │
▼
matchAndFire()
│
┌───────────▼──────────┐
│ Pipeline Runner │
│ context → reduce │
│ → validate → fanout │
└──────────┬────────────┘
│
┌──────────────┴──────────────┐
▼ ▼
Observability ActionRuntime
(MQTT events) (HA calls / timers / MQTT)
MIT