@flowget/builder
v0.3.1
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
Downloads
259
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.
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 />, <DebugDialog />, <RunTestDialog /> as siblings of the toolbar row. 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 scoped to .fg-builder-root. The reset is similarly scoped — it will not fight your app's global CSS, Tailwind preflight, or your design system.
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.
