Skip to content

Blocks

A block is a plain TypeScript module: named typed inputs, named typed outputs, an async process function, three scopes of persistent state, and nothing else injected. No special control-flow primitives, no proprietary expression language.

data/blocks/debounce.ts
import { defineBlock } from "flowbun";
export default defineBlock({
name: "debounce",
config: { ms: 30_000 },
inputs: { signal: {} as { state: string; at: number } },
outputs: { stable: {} as { state: string } },
async process({ signal }, ctx) {
const last = await ctx.state.block.get<number>("lastAt");
if (last !== undefined && signal.at - last < ctx.config.ms) return;
await ctx.state.block.set("lastAt", signal.at);
return { stable: { state: signal.state } };
},
});

process() fires once per message arriving at one named input port; the runtime only ever populates that one port. There are no join semantics — a block that needs cross-port memory uses its own state, exactly how debounce remembers its last timestamp.

State is three-scope SQLite (ctx.state.block, ctx.state.flow, ctx.state.global), stored in WAL mode in a single flowbun.sqlite. Because it lives outside the block’s memory, a debounce timer or “have I fired today” flag survives block reloads, flow restarts, and even kill -9.

A block declares default config values in defineBlock({ config: {...} }). A flow’s wiring file can override any subset per node — defaults merge underneath, so new config fields work without touching existing wiring files.

Ordinary blocks can import anything on npm and fetch any API — but they are never handed a Home Assistant connection. Only the @hass/trigger, @hass/action, and @hass/read boundary blocks (plus direct readEntityState()/performHassAction() calls, which relay through the flow’s single connection) can reach your house.