This document explains the high-level design of flowkit: what the layers are, how the mental model works, how data flows through the system, and who owns which state.
Every visual builder (LLM flows, BPMN, ETL pipelines, CI designers) re-implements the same 80%: a canvas with pan/zoom, a node palette with search, drag-to-add nodes, typed connection ports, schema-driven property forms, an edit inspector, dirty tracking, save/load serialization, dark mode. Flowise built this 80% twice (chatflow v1, agentflow v2). flowkit extracts it once, as a product-agnostic kit, and injects the remaining 20% (what nodes exist, what they mean, where they’re saved) as configuration.
┌────────────────────────────────────────────────────────────────┐
│ CONSUMER APP (BPMN builder / pipeline studio / Flowise) │
│ Supplies ONE FlowBuilderConfig: registry, rules, renderers, │
│ persistence, slots. │
├────────────────────────────────────────────────────────────────┤
│ @openprojectx/flow-builder ← the canvas kit │
│ FlowBuilderCanvas · FlowBuilderProvider · NodePalette │
│ SchemaFields · PropertyInspectorDialog · BuilderNode/Edge │
│ GroupNode · StickyNote · graph/visibility/serialize utils │
├────────────────────────────────────────────────────────────────┤
│ @openprojectx/ui-foundation ← the design system │
│ theme factory (light/dark) · MainCard · StyledButton/Fab │
│ Input/Dropdowns · CodeEditor · JsonEditor · DataGrid │
│ ConfirmDialog · Loader · pickers · hooks · utils │
└────────────────────────────────────────────────────────────────┘Dependency rules, enforced by construction:
-
ui-foundationdepends on nothing flowkit-internal. -
flow-builderdepends onui-foundation(peer) for visuals. -
Neither package imports an API client, a store, a router, or a domain constant. Anything that smells like a product is passed in through config.
|
Important
|
The kit knows HOW to be a visual builder. Your config knows WHAT the builder is about. |
The kit owns the mechanics; the consumer owns the semantics. Concretely:
| The kit knows how to… | Your config decides… |
|---|---|
render a palette, search it, drag nodes out of it |
which nodes exist ( |
drop a node, give it a unique id/label, initialize it |
what a node is ( |
draw handles, drag connections, prevent cycles |
which connections are legal ( |
render a property inspector with a form per param |
which control each param type uses ( |
track dirtiness, suspend Delete while dialogs are open |
what "save" means and where flows go ( |
color nodes and edges from a resolver |
node colors/icons/summaries ( |
registry.list()
│ BuilderNodeSchema[] (palette entries)
▼
NodePalette ──drag──▶ dataTransfer('application/reactflow', schema)
│
▼ drop on FlowBuilderCanvas
getUniqueNodeId / getUniqueNodeLabel
▼
initNode(schema) ── splits raw inputs[] ──▶ inputParams (form fields)
│ inputAnchors (connection ports)
├─ builds typed handle ids
├─ seeds input defaults
└─ runs conditional-visibility pass
▼
React Flow node { id, position, type, data: BuilderNodeData }
│
├── double-click ─▶ PropertyInspectorDialog ─▶ SchemaFields
│ (param.type ─▶ field renderer; edits mutate data.inputs
│ and re-run the visibility engine)
│
└── connect ─▶ isValidConnection? ─▶ edge with gradient colors + label
(+ optional onConnectInput side effect, e.g. {{ref}} writing)
▼
serialize(rfInstance) ─▶ FlowDocument { nodes, edges, viewport } ─▶ onSave(doc)Two kinds of state, deliberately kept apart:
- Kit state (
FlowBuilderProvidercontext) -
-
the React Flow instance (captured via
onInit, no external store) -
graph mutations:
deleteNode,deleteEdge,duplicateNode,onNodeDataChange -
dirty flag (+
config.onDirtyChangebridge so the consumer can mirror it, e.g. into Redux) -
selection flag on node data (
data.selected) -
dialog-open flag (suspends the Delete key while any inspector is open)
-
the edit-target node for the inspector
-
consumer-driven node status badges (
setNodeStatus/clearNodeStatuses)
-
- Consumer state
-
-
persistence entities (flow id, name, timestamps, ownership)
-
domain state (credentials, executions, RBAC)
-
any store it likes — Redux, Zustand, nothing. The kit does not care.
-
|
Note
|
FlowBuilderProvider internally wraps reactflow’s `ReactFlowProvider, so
consumers may also use useStore selectors from reactflow for reactive reads
(e.g. "swap a FAB when the start node’s input type changes").
|
Everything in the kit hangs on three conventions. They are small, serializable, and domain-neutral.
Every handle id encodes node id, direction, param name, and a |-separated type list:
<nodeId>-input-<paramName>-<TypeA|TypeB> (target handle) <nodeId>-output-<outputName>-<TypeA|TypeB> (source handle)
Because the signature is in the id, connection validation needs no registry lookup —
see typesIntersect / respectsListAnchors in packages/flow-builder/src/utils/handles.ts.
data.inputs is Record<string, unknown> keyed by param name. A consumer that wants
"this field is fed by another node’s output" writes a reference string (Flowise’s
convention: {{nodeId.data.instance}}). The kit’s default disconnect logic knows how
to scrub such refs on node/edge deletion; consumers that never write refs are
unaffected. Override with config.refScrubber.
SchemaFields maps inputParam.type to a field renderer from a merged registry
(20 kit defaults < consumer config.fieldRenderers < per-instance overrides, with a
"nodeName:paramName" escape hatch). Conditional visibility is declared in the
schema via show/hide maps over sibling values — no custom form code for 95% of
nodes. See Node model spec.
flowkit deliberately preserves Flowise’s pragmatic data style:
-
Param edits mutate
node.data.inputs[paramName] = valuein place, then the structure-level update (inputParams, visibility) goes throughsetNodes. -
Graph-level changes (add/delete/duplicate/connect) always go through the React Flow instance (
setNodes/setEdges). -
reactFlowInstance.getNodes()is the canonical read; the contextnodes/edgesgetters are imperative snapshots, not reactive subscriptions.
This is not purist immutable state — it is the style the Flowise schema ecosystem already assumes, and keeping it is what makes the kit a drop-in for that world.
| Choice | Rationale |
|---|---|
React Flow v11 ( |
The version Flowise’s canvases are written against; extraction carries zero migration risk. v12 ( |
TypeScript, strict |
The kit is a public API surface; typed config interfaces are the product. Consumers may be JS (Flowise is) — they consume the compiled dist + d.ts. |
MUI v5 + Emotion |
The Flowise design language. The theme factory ships light/dark palettes with custom keys ( |
Vite library mode + |
ESM-only dist with bundled declarations; React/MUI/reactflow externalized as peers to guarantee single instances in the host app. |
pnpm workspace, |
Topological builds without extra orchestration. |
-
No backend calls. Registry, option loading, persistence — all adapter-injected. There is no
fetchin the kit. -
No router. Layout is the consumer’s; the canvas fills a flex box and offers slots.
-
No domain nodes. LLM, BPMN, pipeline operators — all consumer schemas.
-
No undo/redo, sub-graph clipboard, alignment guides (yet — see roadmap in the root README). Flowise never had them either; the extension points won’t fight them.