The node catalog
One exported array is the source of truth for every step type. The palette lists from it, the engine journals from it, the simulator assumes from it, the canvas colours and counts from it, and the field picker reads from it. Adding a step type means adding one entry and satisfying the compiler everywhere it is read — never editing six lists that can drift.
The declaration
export interface BlockDef {
type: NodeType;
label: string; // what a human calls it
category: BlockCategory; // trigger | logic | enrich | fetch | timing | output
description: string; // one line, shown on the palette row
/** 'dynamic' = derived from the node's own config by outletsFor(). */
outlets: string[] | "dynamic";
defaultConfig: Record<string, unknown>;
/** Can executing this node change `vars`? Drives journal delta capture. */
mutatesVars: boolean;
/** Deterministic given `vars` — no network, no clock, no counters. OMIT to
* be safe: anything unmarked is journaled. */
pure?: true;
/** Which outlet a resolved wait leaves by, keyed by its resolution. */
resolutionOutlets?: Record<string, string>;
/** What a preview assumes when it cannot compute the real outlet, and why. */
assume?: { outlet: string; note: string };
/** Paths this type declares it writes, for the builder's field picker. */
contributes: (config: Record<string, unknown>) => string[];
}
Two fields earn their keep by being declarations rather than notes:
assumelives here so renaming an outlet cannot leave the simulator pointing at an edge that no longer exists — and so the preview can print the reason ("assumes approved — a person decides at runtime") verbatim.resolutionOutletsmeans the engine looks up which outlet a resolved wait leaves by instead of switching on node type. A node's outlet vocabulary lives in exactly one place.
The step types
The set below is the working vocabulary of a marketing/journey-style engine.
Six families, eighteen types; trigger is the only one a person cannot add.
| Type | Label | Family | Outlets | Pure |
|---|---|---|---|---|
trigger | Trigger | trigger | out | ✓ |
conditional.rule | Rule branch | logic | dynamic — one per branch label, plus default | ✓ |
conditional.ai | AI branch | logic | dynamic — one per named branch, plus the default | |
guard.frequency | Frequency guard | logic | allow, suppress | |
split.experiment | A/B split | logic | dynamic — one per variant label | ✓ |
flow.call | Group | logic | out | ✓ |
agent | Agent | logic | resolved, needs_confirmation, handoff | |
ai.generate | Generate text | enrich | out | |
ai.extract | Extract fields | enrich | out | |
transform.remap | Remap fields | enrich | out | ✓ |
profile.update | Update profile | enrich | out | |
fetch | Fetch | fetch | out | |
wait | Wait | timing | out | |
wait_until | Wait for event | timing | converted, timeout | |
wait.window | Quiet hours | timing | out | |
wait.approval | Approval | timing | approved, rejected, timeout | |
emit.event | Emit event | output | out | |
deliver | Deliver | output | out |
Notes that matter when you use them:
triggermatches a list of event types (empty = catch-all) plus an optional predicate, and names thesubject_paththat identifies the person the event is about. It is marked pure structurally — entering a flow does nothing observable.conditional.ruleis first-match-wins, top to bottom; anything unmatched leaves viadefault. Its outlets are its branch labels, which makes it the one node whose config edit changes the shape of the graph.split.experimentassigns by a stable hash of (flow, node, subject) — never random, or a replay reassigns the variant. It stays pure and still writes an exposure row, because that write is idempotent on exactly that triple.guard.frequency,wait_until,wait.approvalandagenteach declare anassumeoutlet, because their real outlet depends on delivery history, a future event, a person, or a model.fetchis one node with many sources (http,shopify,klaviyo, and a first-partyconversationsource that reads your own database). Sources run in parallel and fail open per source: one failure leaves its own output key empty and the others still land. Resist a node type per vendor — a vendor in the engine's exhaustive switch buys nothing at runtime.deliveris terminal in practice and holds the complete fan-out: a list of destination ids, with no account-level fallback anywhere.
Dynamic outlets
Three types derive their outlets from their own config, which is why this cannot live in the static catalog:
export function outletsFor(type: NodeType, config = {}): string[] {
const def = BLOCK_BY_TYPE[type];
if (!def) return ["out"];
if (def.outlets !== "dynamic") return def.outlets;
switch (type) {
case "conditional.rule": // labels, plus a default
return [...new Set([...labels(config.branches), "default"])];
case "conditional.ai": // branches, plus the named fallback
return [...new Set([...strings(config.branches), config.default_branch || "default"])];
case "split.experiment": // variant labels, or one plain outlet
return labels(config.variants).length ? [...new Set(labels(config.variants))] : ["out"];
default:
return ["out"];
}
}
Everything else in the app calls outletsFor — the handles on the card, the
plus buttons, the layout's column reservation, the removal planner, the
validator. Deduplicate, and always include the fallback outlet even when no
branch is configured, so a half-built rule still routes.
Purity is a pair, not a flag
Marking a type pure is only half of it: the shared package must also expose an
evaluator for it, and a test asserts the two sets match exactly.
// packages/flow-core — called by BOTH the engine and the builder's simulator.
export const PURE_EVALUATORS = {
"conditional.rule": evalConditionalRule,
"transform.remap": evalTransformRemap,
"split.experiment": evalSplitExperiment,
trigger: evalTrigger,
"flow.call": evalFlowCall,
};
That is what makes it a mechanism rather than a note — and it is why the simulator can claim to route a rule exactly instead of approximately.
Adding a step type: the whole list
Work top to bottom; the compiler catches most of it, and the tests catch the rest.
- Config schema — a Zod object where every field has a
.default(), and references tolerate''. - Union arm — add it to the discriminated union so
confignarrows. - Catalog entry — label, category, one-line description, outlets,
defaultConfig,mutatesVars,contributes. Addpureonly with an evaluator beside it. Addassumeif a preview cannot compute its outlet. - Engine — a case in the one exhaustive
switch. Fail open: a failed lookup or model call records the error and continues. Only an abnormal walk stop fails the run. - Simulator — a case that returns either a real result (via the shared
evaluator) or a labelled placeholder with
approximate: true. - Icon — one glyph in the
Record<NodeType, Icon>, which will not compile without it. - Inspector form — optional; the JSON editor is the guaranteed fallback
(see
flow-builder-inspector). - Portability — if the config holds a connection or destination id, add it
to both
collectRefsandrewriteRefs. Miss this and an export carries a foreign uuid into another account, where it fails open at runtime: enriching nothing, looking fine. - Canvas summary — optional one-line "what will this do" from the config.
The tests that hold the line: the catalog covers every node type exactly once;
every defaultConfig parses against its own schema; journaled is the exact
complement of the pure opt-in; mutatesVars is honest about what writes to
vars; every block has a non-empty description and outlet set.
Presets are palette sugar, never node types
"Send an SMS" is a deliver node pointed at a Twilio destination. "Wait for a
reply" is a wait_until on the provider-neutral inbound-message event. Ship
those as presets — a palette card that lands an ordinary node with its
config filled in:
export interface StepPreset {
id: string; label: string; description: string;
kind: StepKind; category: BlockCategory; vendor?: Vendor;
nodeType: NodeType; // the real type it lands
destinationType?: DestinationType; // so the row can say "configure one first"
configure: (destinationIds: string[]) => Record<string, unknown>;
}
A preset arrives at the landing path as a node type plus a config, so nothing downstream knows a card was a preset and nothing about presets reaches the saved graph. Pre-fill a reference only when there is exactly one candidate — guessing between two SMS senders means texting a real person from the wrong number.