Build Your Own Workflow Product — the Runtime Is the Part You Don't Have to Write
FloMorphic is one product on top of the inflow runtime. Bring a Vue Flow canvas — or a YAML DSL — plus inflow-fusion, and you can build another.
Everyone who has looked at n8n, Zapier, Dify, or FloMorphic has had the same thought at least once: I could build that. Drop a canvas on the page, let people wire boxes together, run the boxes in order. How hard can it be?
The canvas is not the hard part. Vue Flow or React Flow gives you drag, drop, edges, and handles in an afternoon. The hard part is the thing underneath the canvas: an execution engine that takes a graph and actually runs it — durably, across crashes, through loops, pausing for days while it waits on a human or an event, then picking up exactly where it left off. That engine is months of work, and it's the part that decides whether you've built a toy or a product.
Inflowenger ships that engine as a reusable runtime. You don't write it. You bring the two pieces that are genuinely yours — a canvas and your data — and wire them to the engine through the inflow-fusion SDK. FloMorphic, our own product, is built this exact way. This post is the map for building another one.
What you get, and what you write
The cleanest way to see the deal is to split the system into "already written" and "yours."
Already written — the reusable runtime:
- The inflow engine. Given a compiled graph and a start node, it walks the node map, executes each node by type, follows edges, loops on backward edges, and persists state as it goes. Durable and resumable is a property of the engine, not a feature you bolt on. You register one or more engine instances; new runs round-robin across them.
- Infra (the control plane / substrate). A NATS-based message substrate and the account manager on top of it. It carves the system into isolated spaces (NATS accounts), issues scoped credentials, and keeps a live registry of every engine instance. It's the piece that lets the same product run from your laptop to a cloud-native, horizontally-scaled deployment — and can itself be clustered. More on this below.
- The plugin isolation model — and the ecosystem that comes with it. External integrations run as their own processes with credentials scoped so one can't see another's traffic. And because plugins target the runtime protocol rather than any one product, every plugin already written for the Inflowenger ecosystem is available to your product on day one — your integration catalog starts full, not empty.
- The node primitives + the compiler contract. A small, fixed set of node types every higher-level node reduces to, and a defined seam for turning your source format — a graph, a DSL, anything — into the engine's node map.
Yours to write — the product:
- The canvas. A Vue Flow / React Flow editor with whatever node types, forms, and branding your product needs.
- Your data. Flow definitions, run context, and your domain entities, stored however you like.
- The compiler hook. One function mapping each of your on-canvas node types to a runtime primitive.
- Your domain actions. Any business logic a node should be able to call, exposed as "extrinsic" services.
That's the whole shape. The engine is the moat you didn't have to dig. Everything else is product surface you'd want to own anyway.
The four actors
A backend built on inflow-fusion sits inside a cast of four:
- Infra (control plane / substrate). The NATS message substrate plus account, space, and resource management. It hands out scoped credentials, isolates traffic into spaces, and tracks which engine instances are alive. Your backend talks to it over plain REST with a shared-secret bearer JWT.
- Your backend (the SDK). Owns the product data. It does not execute flows. It answers three questions over NATS — get a flow, get a run's context, set a run's context — and optionally exposes extra domain actions.
- The inflow engine. The execution runtime. Give it a
ProcessRequestand it walks the compiled graph, asking your backend for anything it doesn't already have. - Plugins. Isolated external integrations (Jira, Slack, your own) a flow's
Pluginnode hands off to over NATS.
The load-bearing design decision is that the engine never touches your database. It only knows how to ask "give me the flow with this id" and "give me / take this context" over well-known NATS subjects. That's precisely what makes one engine reusable across completely different backends: each backend just answers those three questions however it wants to store the data.
your Vue Flow canvas
│ save graph
▼
your backend (inflow-fusion) ──REST──▶ infra (spaces + Portal registry)
│ ▲ │
answers │ 3 questions over NATS │ round-robin pool
▼ │ ▼
inflow engine ◀── POST /engine ── inflow.NewProcess().Exec()
│
Plugin node ──NATS──▶ isolated plugin process
Infra: the substrate, not just a registry
It's tempting to read "control plane" as a lookup table. Infra is more than that — it's the distributed-systems substrate the whole platform stands on, built on NATS.io, the same messaging fabric that carries every context-aware exchange in an Inflowenger system. Two jobs sit inside it.
Spaces — secure message isolation. Infra manages NATS accounts as spaces, the enterprise-grade boundary that keeps traffic from bleeding across tenants and roles. Inflowenger ships three built-in spaces, and everything authenticates into exactly one of them:
system— the NATS system account. Infra loads and prepares the NATS system itself under JWT auth, and this account is its admin: the privileged identity with high-level access to server/system event subscriptions ($SYS.>— connections, account activity, server stats) and data. It's the operator seat over the substrate, not an application tenant.inflow— your backend and the engines. Yourinflow-fusionprocess authenticates here.plugins— external integrations, each further scoped to its own subject namespace within the space, so one plugin instance can't observe another's messages.
Because the boundary is a real NATS account and not an application-level if, isolation holds at the transport layer. That's what makes the substrate safe to share across tenants — the secure ground the rest of the system is built on.
Resource management via the Portal. Engine instances — the fractal execution units — don't get hardcoded anywhere. Each one registers itself through a gateway called the Portal, and infra keeps the living catalog of what's currently available. inflow-fusion then selects from that catalog: ReloadResources pulls the registered instances into a round-robin pool, and each NewProcess().Exec() picks the next one. Add capacity by standing up another instance and letting it register; the SDK starts routing to it with no code change.
This is the reason the same product scales from a single laptop to a cloud-native, horizontally-scaled deployment without a rewrite: connection, credentials, isolation, and resource discovery are all provided by the substrate. Infra itself can be clustered, so the control plane isn't a single point of failure either. In short, infra is a reusable answer to "how do I build a distributed system on NATS" — one Inflowenger already leans on, and one your product inherits for free.
Step 1 — the canvas is the easy 20%
Author flows in Vue Flow. Each node carries the usual id / type / data / position; each edge carries source / target / sourceHandle / targetHandle. Your node types are whatever your product means — "send-email", "llm-call", "approve", "branch". The data blob is your node's form: whatever fields your UI collected.
You do not teach the canvas how anything executes. It's an editor. Its only job is to export { nodes, edges }.
And if you're on React Flow instead — you're covered by the same compiler. Vue Flow's data model intentionally mirrors React Flow's (they're the same idea in two frameworks), so a React Flow export decodes into the same structs unchanged. The shipped compiler works for both; the package name just reflects which one it was first built against.
A canvas isn't a requirement, though — it's just the input format the one shipped compiler happens to read. The next section is the important part: the compiler stage is a pluggable seam, and your source format can be anything that describes steps and transitions.
Step 2 — the compiler hook lowers your nodes to primitives
The engine only ever executes map[string]*models.Node. It never parses whatever JSON your editor produced. The compiler is the piece in between, and inflow-fusion ships one for Vue Flow / React Flow-shaped graphs.
You supply one hook — func(VueFlowNode) (*models.Node, error) — that reads a node's form data and returns the matching runtime node. You do not wire up edges yourself; the compiler fills in each node's Next from the graph's edges as it walks.
func myNodeBuilder(vfn compiler.VueFlowNode) (*inflowModels.Node, error) {
data := vfn.Data.(map[string]any)
node := inflowModels.Node{ID: vfn.ID, Title: data["title"].(string)}
switch vfn.Type {
case "code":
node.Type = inflowModels.CodeNodeType
n := inflowNodes.NewJsNode(data["logic_rule"].(string))
node.Code = &n.CodeRule
case "contract":
node.Type = inflowModels.RuleNodeType
n := inflowNodes.NewJsRuleLogicNode(
inflowNodes.WithContractLogicCode(data["logic_rule"].(string)))
node.Contract = &n.ContractRule
// ... your other node types
}
return &node, nil
}
cmpr := compiler.NewVueFlowCompiler(compiler.WithEachNodeFunc(myNodeBuilder))
nodeMap, errsByNodeId := cmpr.Compile(startNodeId, vueFlowGraph)
Compile walks depth-first from the start node, calls your hook on each node, and for every outgoing edge appends a Next (target id + edge tags + handle metadata). That single detail is why branching needs no special case in the compiler: a node with success / error / else handles is just a node with three tagged edges, and it's the runtime's own rule logic that decides which tagged Next fires at runtime.
This hook is the entire "how does my product's node mean something" surface. Everything product-specific lives here. Position, dimensions, and editor-only metadata never leave your compiler.
The primitive set you're lowering to
You never invent execution semantics. You map onto six primitives:
| Node | Role |
|---|---|
| Void | No-op: start markers, joins, dead-ends |
| Code | Run JS/OPA logic, write result into context |
| Contract | Branch: rule output = tags that select which Next edges fire |
| Extrinsic | Call an internal service you own (NATS request/reply) |
| Plugin | Hand off to a live external process with its own UI and jobs |
| GoTo | Jump into another (or the same) flow and return |
The claim behind this small set is deliberate: any higher-level node a workflow builder needs reduces to these. A flashy "Send Slack message" node on your canvas is a Plugin (or Extrinsic) underneath. A "wait for approval" node is a park-and-resume built from these primitives. Your job is the lowering, not the execution.
The compiler is a seam, not a canvas — your input can be anything
Here's the part that surprises people. The engine executes a node map. A compiler produces a node map. Nothing in that sentence says "visual."
The shipped compilers/vueFlow reads a { nodes, edges } graph because that's what Vue Flow and React Flow export — but the compiler contract is just:
- A graph type describing your external representation. It doesn't have to be nodes-and-edges. It's whatever your format is.
- A constructor + a hook where your format's per-step data becomes a
models.Node. - A
Compile(startNodeId, yourFormat)method that walks your format and populates each node'sNextfrom your format's transitions — edges,needs:dependencies, plain top-to-bottom order, whatever.
Want to support a different diagram library — Cytoscape.js, Litegraph, a bespoke canvas? Write a compiler for it (contribute it upstream to inflow-fusion, or fork). Want no diagram at all? That's fine too. A compiler can read a non-visual DSL — a YAML or JSON file — and lower it to exactly the same node map. The engine never knows the difference; it only ever sees primitives with their Next filled in.
Worked example: a GitHub-Actions-style product on YAML
Suppose you're not building an n8n at all — you're building a CI-style product where people author pipelines in YAML, like GitHub Actions. No canvas anywhere. Your source format is text:
jobs:
build:
steps:
- run: npm ci
- run: npm run build
test:
needs: build # sequential dependency
steps:
- run: npm test
lint:
needs: build # runs in parallel with `test`
steps:
- run: npm run lint
deploy:
needs: [test, lint] # join: waits for both
steps:
- run: ./deploy.sh
You write a compilers/githubActions package. Its graph type is your own parsed structs — Jobs, Steps, needs. Its Compile walks that structure and emits the runtime node map, translating the two things every workflow language has — sequence and parallelism — into Next and primitives:
- A sequence of steps inside a job → a straight chain of nodes, each one's
Nextpointing at the following step. Eachrun:step becomes a Code, Extrinsic, or Plugin node depending on how you execute commands. - A fan-out (two jobs both
needs: build) →build's node gets twoNextentries. The engine follows both; that's your parallelism, expressed purely as multiple outgoing transitions. - A join (
deployneeds[test, lint]) → a Void node that both branches point into, sodeployonly proceeds once its inbound transitions have arrived. Void is the no-op primitive built for exactly these split/join markers. - A conditional (
if:on a job) → a Contract node whose rule output is the tag that selects whichNextfires — the same branching mechanism a visualsuccess/errorhandle compiles to.
The YAML above lowers to the same shape a hand-drawn graph would: build → (test ∥ lint) → deploy. Once it's a node map, it inherits everything the runtime provides for free — durability, resumption, long waits, per-run context — none of which GitHub Actions' own YAML gives you, and none of which you had to build.
That's the real leverage of the compiler seam: you choose the authoring experience your users deserve — a canvas, a YAML file, a JSON API, a domain DSL — and you write one small translator. The durable execution underneath is identical no matter which front door you offer.
Step 3 — own your data by answering three questions
Your backend calls inflow.InitBackend(...) on startup. That fetches NATS credentials from infra, connects, subscribes to the three default subjects, and loads the engine pool. From then on, your backend's contract with the engine is just three handlers:
| Subject | The question | You return |
|---|---|---|
inflow.req.flow.get.{flowId} | "Give me this flow" | the compiled models.Flow |
inflow.req.context.get.{contextId} | "Give me this run's state" | the current ContextDoc |
inflow.req.context.set.{contextId} | "Persist this run's state" | (store it) |
That's the whole storage contract. Postgres, Mongo, SQLite, a file — the engine doesn't know and doesn't care. Context is opaque to the SDK (Data is typically just JSON you encode however your nodes expect). This is what "the engine is storage-ignorant" buys you: total freedom over your own data model.
Step 4 — plug in your domain logic
Two extension points cover the rest of a real product.
Extrinsic services are internal actions your backend owns — "charge this card", "write this row", "call our pricing service." You register a subject and a handler; an Extrinsic node on the canvas invokes it and the handler's return value becomes the node's output:
svcHandler.ImplHandlerOnSubject("db_handler",
svcHandler.SvcTopic("my.internal.svc.persist.*"),
func(header nats.Header, data []byte) ([]byte, error) {
table := strings.Split(header.Get("recv_subject"), ".")[4]
// ... persist, then:
return []byte(`{"status":"saved"}`), nil
})
Plugins are the open-ended integration node: full external processes with their own UI and background jobs, each sandboxed to its own NATS namespace. This is how you grow an integration catalog — the "500 apps" wall of any automation product — without any of them being able to see each other's traffic.
And here's the part that turns a catalog into a head start: you don't start that catalog from zero. A plugin isn't written against FloMorphic, or against your product — it's written against the Inflowenger runtime protocol, the contract every product on this runtime speaks. So every plugin already written for the Inflowenger ecosystem is available to your new product on day one. The Jira plugin, the Slack plugin, the vector-store plugin someone built for their own flows — all of them show up as native palette nodes in your product without you (or the plugin author) doing a thing. Write a plugin once; it runs in every product built on the runtime. That's the difference between shipping a tool and joining an ecosystem: your integration story starts full, and it grows sideways as anyone, anywhere, writes the next plugin.
Step 5 — run it
Starting a run is a single call. Round-robin picks an engine instance and POSTs a ProcessRequest to it:
inflow.NewProcess(startNodeId,
inflow.WithFlowId(flowId),
inflow.WithContextDocument(seedContext),
).Exec(ctx)
From there the engine drives: it fetches the flow and context from your subscriptions, executes node by node, calls your extrinsic handlers, hands off to plugins, jumps between flows on GoTo, and persists context as it goes. Settings cap per-request and whole-process timeouts and the number of nodes a run may visit. You can stop a run early with inflow.StopProcess(...).
Nothing about "durable, resumable, loops for days" is code you wrote. It's the engine's nature.
Why this beats building the engine yourself
You could write the runtime. People do — and it's where workflow products go to die. The moment you support real loops and long-running waits, you're on the hook for state persistence, crash recovery, exactly-once-ish semantics, resumption, engine scaling, and tenant isolation. That's the actual product underneath every automation tool, and it has almost nothing to do with what makes your product distinctive.
What makes your product distinctive is the part you keep: your node catalog, your forms, your integrations, your data model, your UX, your vertical. inflow-fusion draws the line in exactly the right place — the engine is generic and reusable, and everything above the three-question contract is yours to shape.
FloMorphic is proof by construction. It's a Vue Flow canvas, an inflow-fusion backend, a compiler hook, and a pile of domain-specific nodes — sitting on the same runtime described here. There's nothing privileged about it. The runtime is available. Build your own.
Where to go next
- Architecture — the four actors and the end-to-end request flow.
- Compilers and the Vue Flow compiler — the compiler contract and the shipped hook.
- Nodes — the primitive set and how any node reduces to it.
- Infra & wire reference — the exact REST endpoints, NATS subjects, and payload shapes.