@flowget/builder
v0.8.3
Published
Composable React workflow builder for Flowget — Provider + compound subcomponents, token-driven theming, replaceable primitives, slot-based customization, and position-optional graph loading (autoLayout / mergeGraph via dagre) for code-generated and AI-au
Maintainers
Readme
@flowget/builder
Composable React workflow builder for Flowget — Provider + compound subcomponents, token-driven theming, replaceable primitives, slot-based customization.
Alpha (
0.1.0-alpha.0). See CHANGELOG.md for phase-by-phase history of what's shipped. The README below documents the currently shipped surface only — no future-promised features mixed in.
Full documentation
The README is the quick-reference. Long-form docs live under docs/:
- Getting started — install, quickstart, mental model
- Adapters — Storage / Execution / Catalog
- Defining nodes — catalog shape, field types, custom types
- Customization — theming, primitives, renderers
- Runtime — state events, node status, validation
- Composition — default vs compound, subcomponents
- Hooks — every public hook
- Recipes — end-to-end walkthroughs
- Philosophy — why the package draws the lines it draws
Install
npm install @flowget/builder @xyflow/react react react-dom
# or: bun add / pnpm add / yarn add — package is PM-agnosticPeer dependencies (you bring them):
react^18.3 || ^19react-dom^18.3 || ^19@xyflow/react^12.0— v11 is not supported. The builder is built on@xyflow/react@12; v11 has a different node/edge data shape and connection API.
Internal state is powered by zustand and bundled into the published artifact — you do not need to install zustand yourself. The bundled instance has private module identity, so your app's zustand stores stay isolated from the builder's.
Quick start
// app/workflows/[id]/page.tsx
import { FlowgetBuilder } from "@flowget/builder";
import "@flowget/builder/styles.css";
import { storage, execution, catalog } from "./adapters";
export default function Page({ params }: { params: { id: string } }) {
return (
<FlowgetBuilder
workflowId={params.id}
storage={storage}
execution={execution}
catalog={catalog}
/>
);
}The default <FlowgetBuilder> composes Provider + Toolbar + ActiveRunBanner + Catalog + Canvas + Inspector + HitlSlot.
Adapter optionality
Only storage is required. Omit any of the others to switch off the corresponding surface:
// Embeddable read-only past-run viewer — no execute, no edit, schema-evolution-resistant
<FlowgetBuilder workflowId={pastRunId} storage={storage} />| Adapter | Optionality | Effect when omitted |
|---|---|---|
| storage | required | (can't render without a workflow to load) |
| execution | optional | Toolbar drops Run / Cancel / status pill; ActiveRunBanner becomes inert |
| catalog | optional | Unknown node types render via a raw-shape fallback (type id + data snapshot, no field-aware UI). Catalog sidebar shows a "no catalog provided" state. Lets you render past workflows whose node types have evolved since save. |
| hitl | optional | HITL surfaces silently absent; HITL signals (if any) are no-ops |
Conditional adapters under exactOptionalPropertyTypes: true (the recommended TS setting) require the conditional-spread shape:
<FlowgetBuilder
storage={storage}
{...(includeExecution ? { execution } : {})}
{...(includeCatalog ? { catalog } : {})}
/>Passing execution={undefined} is rejected under strict optional-property semantics. Either omit the prop entirely or use the spread pattern above.
Compose your own layout when the default doesn't fit:
<FlowgetBuilder.Provider storage={storage} execution={execution} catalog={catalog}>
<FlowgetBuilder.Toolbar />
<main style={{ display: "flex", flex: 1 }}>
<FlowgetBuilder.Catalog />
<FlowgetBuilder.Canvas />
<FlowgetBuilder.Inspector />
</main>
<FlowgetBuilder.HitlSlot strategy="drawer" />
</FlowgetBuilder.Provider>Compose your own Toolbar
The Toolbar accepts children; sub-primitives (SaveAction, RunAction, DebugAction, etc.) consume internal hooks for their state, so customer composition needs zero context plumbing:
<FlowgetBuilder.Toolbar>
<FlowgetBuilder.Toolbar.Breadcrumb />
<div className="fg-toolbar__actions">
<FlowgetBuilder.Toolbar.SaveAction />
<MyCustomButton />
<FlowgetBuilder.Toolbar.DebugAction />
<FlowgetBuilder.Toolbar.RunAction />
<FlowgetBuilder.Toolbar.StatusPill />
</div>
</FlowgetBuilder.Toolbar>Default behavior (no children) renders the standard cluster. The same pattern applies to Catalog, Inspector, Canvas, and HitlSlot. Item-mapping renderers (e.g. <ValidationOverlay renderIssue={...} /> and the nodeRenderers / edgeRenderers / fieldRenderers registries) stay render-prop / registry — that's the architect-locked seam from CAR-447 §C.3.
Validation: the panel is opt-in, the run/save gate is always live
The builder ships graph validators (≥1 trigger, no orphan trigger, decorator allow-list, DAG, per-node config) plus the shared @flowget/graph-validation static layer (unknown node type, template reference existence/reachability, whole-string type-compat, and declarative field rules — required / coarse type / enum / min / max / pattern). validateGraph is a thin adapter that concatenates both, so the builder's pre-submit verdict equals the worker's pre-flight verdict by construction.
Two things are separate:
- The visual panel is opt-in.
<FlowgetBuilder>mounts neither<AutoValidate>nor<ValidationOverlay>, so an empty / trigger-less canvas shows no "needs a trigger" panel unless you wire the two primitives throughchildren. - The run/save gate is always live. The toolbar
<RunAction>/<SaveAction>and<RunTestDialog>derive a livevalidateGraph(graph, catalog)blocking-error count on every render — independent of whether<AutoValidate>is mounted. There is no dark-store window where an invalid graph slips through.
import { FlowgetBuilder, AutoValidate, ValidationOverlay } from "@flowget/builder";
<FlowgetBuilder storage={storage} catalog={catalog}>
<AutoValidate /> {/* runs validateGraph on every mutation → store */}
<ValidationOverlay /> {/* renders the resulting issues + honesty note, bottom-left */}
</FlowgetBuilder>;validationMode — gate save too, or keep drafts saveable
<FlowgetBuilder storage={storage} catalog={catalog} validationMode="strict" /> // default"strict"(default, founder decision) — gate BOTH save and run on an invalid workflow. A draft can never be persisted in a state the worker's pre-flight would reject."run-only"— gate run only; drafts stay saveable. Run is gated in both modes.
Honesty boundary
The static layer is coarse by design — it reasons only over node.json declarations (ConfigField.valueType / OutputField.type) on the FieldValueType lattice; it never sees a live zod schema. A "valid" verdict is therefore never WRONG but is not COMPLETE: refinements (.int(), .email(), .positive(), .literal(…)) stay enforced at run time by the worker. The <ValidationOverlay> panel states this to the customer.
<AutoValidate>(panel) and the shared static layer both require acatalog— a catalog-less builder is un-gated (unknown node types render via the raw-shape fallback).- Want a subset of rules, custom rules, or an i18n-aware surface? Omit
<AutoValidate>and run your own effect against the exportedvalidateMissingTrigger/validateGraph/ etc., pushing results via the store'ssetValidationIssues.getRunDisabledReason/getSaveDisabledReasonremain the pure helpers the toolbar<RunAction>/<SaveAction>consume. <ValidationOverlay>isposition: absolute, so mounting it at the end of the tree (underchildren) is visually identical to an inline placement.
Changed in 0.4.0: the panel was previously mounted by default. Changed in 0.8.0:
validateGraphbecame a thin adapter over@flowget/graph-validation, and the run/save gate is now always live (defaultvalidationMode: "strict"). See the CHANGELOG.
Debug is opt-in
The Debug button (<Toolbar.DebugAction>) and its JSON-inspector dialog (<Toolbar.DebugDialog>) are a dev tool the builder does not surface by default — the default <Toolbar /> cluster renders neither. Turn them on with the debug prop. For the common default-composition case it's a one-liner:
<FlowgetBuilder storage={storage} debug /> {/* forwards to the internal <Toolbar debug /> */}Composing your own toolbar? Pass debug to <Toolbar> directly:
<FlowgetBuilder.Provider storage={storage}>
<FlowgetBuilder.Toolbar debug /> {/* mounts the Debug button + dialog */}
{/* ...Catalog / Canvas / Inspector... */}
</FlowgetBuilder.Provider><FlowgetBuilder>'sdebugis a pass-through to its internal<Toolbar debug />;<Toolbar>'sdebugis the real gate. Both default tofalse.debugonly affects the default cluster. When you supply your own<Toolbar>children, mount<Toolbar.DebugAction>/<Toolbar.DebugDialog>yourself — the prop is ignored on the custom-children path.- Both primitives stay exported (
Toolbar.DebugAction/Toolbar.DebugDialog+ the top-levelDebugAction/DebugDialog), so nothing is lost — only the default auto-mount is gated.
Changed in 0.5.0 (CAR-1120): the Debug button/dialog was previously mounted by default. See the CHANGELOG for the one-line migration.
Reading builder state from your components
import {
useFlowgetBuilder, // mega-hook — all slices at once
useFlowgetSelection, // { selectedNodeIds, primary, setSelection, addToSelection, ... }
useFlowgetWorkflow, // { graph, isDirty, addNode, updateNodeData, moveNode, ... }
useFlowgetExecution, // { status, events, beginRun, ... } | null ← null if no ExecutionAdapter
useFlowgetValidation, // { issues, blockingErrors, warnings, setIssues }
useFlowgetNodeStatus, // per-node status derived from execution events (memoized)
useFlowgetTheme,
useFlowgetPrimitives,
} from "@flowget/builder";The canonical Q1 use case — "row click in side panel → select corresponding node in canvas":
const { setSelection } = useFlowgetSelection();
// ...
<button onClick={() => setSelection([targetNodeId])}>Inspect</button>useFlowgetNodeStatus(nodeId) returns the node's current run status ("idle" | "running" | "succeeded" | "failed" | "cancelled" | "skipped") derived from the execution event stream. Memoized on (nodeId, events) so the O(events) scan only runs when events arrive, not per render.
Per-node context menu (ui.menu)
Catalog authors declare per-node menu items via BuilderHints.ui.menu. Right-clicking the node opens the menu; selection dispatches the Provider's onNodeMenuAction:
// In the catalog
const myNode: NodeMetadata<BuilderHints> = {
id: "http_request",
category: "action",
handles: [/* ... */],
label: "HTTP Request",
ui: {
menu: [
{ type: "item", id: "copy_curl", label: "Copy as cURL" },
{ type: "item", id: "test_endpoint", label: "Send test request", disabledIfRunning: true },
{ type: "separator", id: "sep1" },
{ type: "item", id: "view_logs", label: "View recent logs" },
],
},
};
// In the page
<FlowgetBuilder
storage={storage}
catalog={catalog}
onNodeMenuAction={(itemId, nodeId, ctx) => {
if (itemId === "copy_curl") copyCurlForNode(ctx);
if (itemId === "test_endpoint") fireSingleNodeTest(ctx.nodeId);
// ...
}}
/>NodeMenuContext carries { workflowId, nodeId, nodeType, data } — enough for routing decisions without re-querying the store.
Writing a custom node renderer
The nodeRenderers registry maps a node type to a component that renders that node on the canvas. Customer components accept React Flow's NodeProps directly — Flowget-specific data (catalog metadata, status, validation) reaches them via hooks:
import { type NodeProps } from "@xyflow/react";
import {
useFlowgetNodeStatus,
NodeHandles,
} from "@flowget/builder";
type SlackData = { channel?: string };
function SlackMessageCard({ id, type, data, selected }: NodeProps<SlackData>) {
const status = useFlowgetNodeStatus(id);
return (
<>
<NodeHandles typeId={type ?? ""} />
<div className={`slack-card slack-card--${status}`} data-selected={selected}>
<h3>Slack message</h3>
<p>Channel: {data.channel ?? "(unset)"}</p>
</div>
</>
);
}
<FlowgetBuilder
storage={storage}
catalog={catalog}
nodeRenderers={{ slack_message: SlackMessageCard }}
/><NodeHandles typeId={...} /> reads the catalog's HandleSpec[] for the node type and renders the right RF <Handle> elements with the right positions + ids. Use it to avoid hand-positioning Handle elements (which can drift away from the catalog's declared shape). If you prefer to wire Handles yourself, import { Handle, Position } from "@xyflow/react" and render them directly — same pattern, different ergonomics.
Same approach for edgeRenderers (customer accepts RF's EdgeProps, uses BaseEdge / getBezierPath / etc. from @xyflow/react).
HITL — human-in-the-loop signals
Customer pushes a HITL signal onto the queue (typically from an adapter integration when an execution event indicates a node is awaiting human input):
import { useFlowgetHitl } from "@flowget/builder";
function MyHitlTrigger() {
const hitl = useFlowgetHitl();
return (
<button onClick={() => hitl.pushSignal({
key: "approve",
nodeId: "n5",
nodeType: "approval_step",
request: { reason: "User exceeded daily limit" },
})}>
Simulate signal
</button>
);
}<HitlSlot /> reads the first pending signal and delivers it to the customer's HitlSlot component (registered via <Provider hitl={MyHitlSlot}>). Four rendering strategies:
strategy="modal"(default) — wraps in the Dialog primitivestrategy="drawer"— wraps in a side panelstrategy="inline"— renders in-place, customer handles positioningstrategy="off"— skips the slot; customer mounts<HitlSlot.Renderer />elsewhere in their tree
Customer's HitlSlot component receives { open, onClose, signal, onResolve } and decides the UI (form, chat, approval buttons, etc).
Toolbar dialogs
Default <Toolbar /> auto-mounts <SaveWorkflowDialog /> and <RunTestDialog /> as siblings of the toolbar row (<DebugDialog /> only when <Toolbar debug /> — see Debug is opt-in). They wire to existing store state (saveDialogOpen / debugDialogOpen / runTestDialogOpen) and use the customer-replaceable Dialog primitive. Customer composing their own Toolbar children takes responsibility for mounting their own (or omitting them):
<FlowgetBuilder.Toolbar>
<FlowgetBuilder.Toolbar.Breadcrumb />
<FlowgetBuilder.Toolbar.SaveAction />
<MyCustomButton />
<FlowgetBuilder.Toolbar.RunAction />
{/* Mount the dialogs you want */}
<FlowgetBuilder.Toolbar.SaveWorkflowDialog />
<FlowgetBuilder.Toolbar.RunTestDialog />
{/* Omit DebugDialog if not needed */}
</FlowgetBuilder.Toolbar>Composing your own Inspector
The Inspector is a Hybrid C area-slot — empty children renders Header + Body + ExpandFieldDialog; pass children for full layout control:
<FlowgetBuilder.Inspector>
<MyCustomHeader />
<FlowgetBuilder.Inspector.Body />
<FlowgetBuilder.Inspector.VariablesPanel />
<MyCustomDocsLink />
<FlowgetBuilder.Inspector.ExpandFieldDialog />
</FlowgetBuilder.Inspector>For per-field control, render <Inspector.Field field={...} /> directly:
<FlowgetBuilder.Inspector>
<FlowgetBuilder.Inspector.Header />
<div className="my-grid">
<FlowgetBuilder.Inspector.Field field={urlFieldDecl} />
<FlowgetBuilder.Inspector.Field field={methodFieldDecl} />
</div>
</FlowgetBuilder.Inspector>For maximum control, use the hooks directly — your component manages everything:
import { useFlowgetInspector, useFlowgetField } from "@flowget/builder";
function UrlField() {
const { value, setValue, field, issues } = useFlowgetField<string>("url");
if (field === undefined) return null;
return (
<label>
{field.label ?? "URL"}
<input
value={value ?? ""}
onChange={(e) => setValue(e.target.value)}
aria-invalid={issues.some(i => i.severity === "error")}
/>
</label>
);
}Writing a custom field renderer
The fieldRenderers registry maps a FieldType (or custom:${string} escape hatch) to a component. Customer components accept FieldRendererProps<TValue> and render against your design system:
import type { FieldRendererProps } from "@flowget/builder";
function MyText({ field, value, onChange, disabled, issues }: FieldRendererProps<string>) {
return (
<div className="my-field">
<label>{field.label ?? field.key}</label>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
/>
{issues?.map(i => <p key={i.id} className={`alert alert-${i.severity}`}>{i.message}</p>)}
</div>
);
}
<FlowgetBuilder
storage={storage}
catalog={catalog}
fieldRenderers={{
text: MyText,
"custom:colorpicker": MyColorPicker,
}}
/>Precedence chain (resolved internally by pickFieldRenderer):
fieldRenderers["custom:xyz"]if the catalog declarescustomType: "xyz"defaultFieldRenderers["custom:xyz"](rare — customer-provided defaults registered globally)fieldRenderers[field.type]defaultFieldRenderers[field.type]UnknownFieldFallback— renders raw JSON + "no renderer registered" message
Wrap-not-replace shape: customer imports the default and composes:
import { TextField, type FieldRendererProps } from "@flowget/builder";
function MyWrappedText(props: FieldRendererProps<string>) {
return (
<div className="my-field-wrapper">
<TextField {...props} />
</div>
);
}
onInsertVariableis @experimental in the current version. Customer renderers receiveonInsertVariable(token)onFieldRendererProps, but the focus-routing wiring that delivers VariablesPanel clicks to the right field lands in a future iteration. CallingonInsertVariablefrom a renderer today is a no-op; the contract shape is stable, only the wiring is missing. See the JSDoc onFieldRendererProps.onInsertVariablefor the focused-field model planned next.
Composing your own catalog sidebar
The Catalog is a Hybrid C area-slot — empty children renders the default body (Search + grouped List); pass children to take full layout control:
<FlowgetBuilder.Catalog defaultCollapsed={false}>
<MyCustomBranding />
<FlowgetBuilder.Catalog.Search placeholder="Search nodes..." />
<MyCustomDivider />
<FlowgetBuilder.Catalog.List
grouping="category"
itemRenderer={(node) => (
<li className="my-card" data-category={node.category}>
<strong>{node.label ?? node.id}</strong>
{node.description ? <span>{node.description}</span> : null}
</li>
)}
emptyState={<p>Bring your own empty-state copy here.</p>}
noResultsState={<p>Nothing matches your query.</p>}
/>
</FlowgetBuilder.Catalog>grouping="category"(default) groups items byNodeCategory. Set to"none"for a flat list.itemRendereris the render-prop override per item — Hybrid C item-mapping pattern. Customer's renderer is fully responsible for the<li>(or whatever element) shape, including drag-out handling if needed (text/flowget-node-typepayload).defaultCollapsedinitializes uncontrolled collapse state. Customer who needs persistence wraps<Catalog>and forwardscollapsedthrough their own state machine.hideCollapseTogglehides the built-in toggle button when customer drives collapse externally.
Default item shape (<Catalog.Item />) is composable too — drop the itemRenderer and the default item renderer handles drag-out + collapsed-mode automatically.
Switching workflows: remount the Provider
<FlowgetBuilder.Provider> holds the Zustand store across re-renders, but workflowId / initialGraph are baked at first mount — they don't re-initialize on prop change. To switch between workflows inside the same parent tree, give the Provider a React key:
<FlowgetBuilder
key={currentWorkflowId} // remounts the whole builder + store on workflow change
workflowId={currentWorkflowId}
storage={storage}
catalog={catalog}
/>Mid-mount adapter / handler swaps are picked up via React context — the store stays intact. Only the workflow seed and the per-mount selection / execution state need a full remount.
CSS
import "@flowget/builder/styles.css"; // complete visual contract (recommended)
// — or à la carte (partial — see note below) —
import "@flowget/builder/tokens.css"; // only design tokens
import "@flowget/builder/reset.css"; // only the scoped resetstyles.css is the complete visual contract — design tokens + scoped reset + builder component styles + the @xyflow/react base layer (pane cursor, edge geometry, viewport transforms, zoom/pan). It is self-contained: you do not need to import @xyflow/react/dist/style.css separately (doing so anyway is harmless — the rules are idempotent). The à-la-carte tokens.css / reset.css are partial — they do not carry the builder component styles or the React Flow base, so use them only if you're assembling the full sheet yourself.
Themes are token overrides. The builder ships a neutral default plus data-theme="flowget" (brand) and data-theme="light" (light variant) opt-ins. Define your own by setting data-theme="<name>" and shipping a CSS rule:
[data-theme="acme"] {
--flowget-color-accent: #ff6b35;
--flowget-color-bg: #0b0e14;
/* …override any of the ~45 documented tokens — see src/styles/tokens.css for the full list */
}<FlowgetBuilder theme="acme" />All tokens live under the --flowget-* namespace and are defined at :root; theme overrides apply via a [data-theme="<name>"] selector wherever that attribute is set. (Because tokens are global rather than scoped to the builder subtree, portalled UI that renders outside .fg-builder-root — e.g. a context menu portaled to document.body — still needs data-theme stamped on its own portal root to pick up a scoped override.) The reset, by contrast, is scoped to .fg-builder-root — it will not fight your app's global CSS, Tailwind preflight, or your design system.
The catalog maturity-hint chips expose their colors as public tokens too — --flowget-color-chip-new / -beta / -soon / -text (defaults #22c55e / #f59e0b / #6b7280 / #ffffff), all :root-overridable. --flowget-color-chip-new is deliberately shared with @flowget/templates, whose template-card "New" chip reads the same token, so a single :root { --flowget-color-chip-new: … } override recolors the chip in both packages. (See maturity hints.)
Extension authors — @flowget/builder/internal
For third-party
@flowget/<ext>-style packages, not end-users. If you're embedding the builder in your app, you never import this.
The builder's React contexts + accessor hooks (useBuilderStoreApi, adapters, theme, primitives, renderers, callbacks) are published at the bare specifier @flowget/builder/internal. Every entry of this package — including the Provider and the useFlowget* hooks — imports them from there, so the createContext calls live in one shipped module that a bundler resolves once.
If you publish a component package that renders inside a customer's <FlowgetBuilder.Provider> (a custom field renderer, node renderer, inspector panel, etc.) and needs builder state, import from @flowget/builder/internal and mark both @flowget/builder and @flowget/builder/internal as peerDependencies + bundler external — exactly how you'd externalize react. That guarantees your extension reads the same Provider state the host writes, instead of bundling its own (dead) context instance.
// your @flowget/my-extension package.json
"peerDependencies": {
"@flowget/builder": "^0.1.0",
"react": "^18 || ^19"
}// your extension component
import { useBuilderStoreApi } from "@flowget/builder/internal";Architecture
Compound-component pattern (Radix-style). Provider injects adapters + theme + primitives + renderers; named subcomponents read from the React context tree and from the per-mount Zustand store.
Per architect verdict on CAR-447 §C.3 (Hybrid C):
- Area slots — Toolbar / Catalog / Canvas / Inspector / HitlSlot — accept
children. Sub-primitives use hooks for state. No render-prop ceremony, no(ctx) =>plumbing. - Item-mapping slots —
renderIssueon ValidationOverlay;nodeRenderers/edgeRenderers/fieldRenderersregistries — kept as render-prop / registry (iteration boundary makes children syntactically awkward there). - Primitives —
Dialog,Tooltip,Popover,ContextMenu,Toast— replaced wholesale via<Provider primitives={{ Dialog: MyDialog }}>. Defaults are minimal HTML + ARIA; bring your own Radix / Headless UI / custom set if you need real focus traps / positioning. Primitives are headless: Flowget does not inject classes or styles on them. Customer-supplied primitives carry their own styling — what you ship is exactly what renders. The default primitives use the.fg-dialog-*/.fg-tooltip/.fg-popoveretc. classes you can target from your token overrides, but replacement primitives are on their own.
The full architecture spec is CAR-447.
Developing locally
bun install # or npm / pnpm / yarn
bun run build # tsup → dist/{index.js,index.cjs,index.d.{ts,cts}} + dist/*.css
bun run typecheck
bun run lint
bun run testLicense
FSL-1.1-ALv2. See LICENSE.
