TL;DR
- A visual workflow builder for AI agents is a canvas of connected nodes plus an engine that runs them in dependency order. The canvas is a UI problem (drag, connect, position); the engine is a graph problem (topological sort over a directed acyclic graph, or DAG).
- React Flow (published as the
@xyflow/reactpackage, current version 12.11.3, MIT-licensed) is the standard open-source library for the canvas half. It renders nodes and edges, handles dragging and zoom, and exposes hooks for reading and mutating the graph's state. - The execution half — deciding what runs, in what order, with what inputs — is not something a canvas library provides. It has to be written as a small, separate engine: sort the graph, run each node's handler, and pass each node's output into the nodes that connect from it.
- A node that needs live web data (a "fetch" or "crawl" node) needs a real fetch layer behind it, not just a
fetch()call — JavaScript-rendered pages and anti-bot protection will silently break a hand-rolled HTTP request long before the rest of the workflow does. - This article's execution engine was actually run, not just described: a four-node graph (trigger → crawl → prompt → output) was topologically sorted and executed end to end in this environment, with real captured output shown below.
- A hand-built workflow builder is a real, buildable weekend project with two verified open-source pieces (React Flow plus a topological sort), but it ships with none of the operational features (auth, retries, node-level logging, versioning) that a production tool needs — this article states that limit explicitly rather than glossing over it.
Introduction: what "building your own agent workflow builder" actually means
An AI agent workflow builder is, structurally, two separate systems wearing one UI: a canvas where a person arranges and connects nodes, and an engine that reads that graph and actually executes it. Search interest in "open agent builder"-style tools has grown alongside no-code and low-code AI platforms, but most of what shows up for that phrase is either a hosted product with no visible code, or a short demo that draws a nice canvas and never actually runs anything. This article builds the real thing: a canvas using the open-source React Flow library, and a small dependency-ordered execution engine wired to a live-data-fetching node, with the execution engine actually run and its output captured below.
Two things are out of scope on purpose. First, this is not a walkthrough of any specific commercial or open-source workflow product — it teaches the underlying pattern with a neutral library so the result is yours to extend. Second, the "AI" in a node here means the node's handler calls a language model or a tool; the graph engine itself has no opinion about what a node does internally, which is exactly what makes it reusable for non-AI automation too.
Install: React Flow and a minimal project
React Flow ships as @xyflow/react on npm. At the time of writing, the published version is 12.11.3 (confirmed directly against the npm registry, and installed cleanly with a plain npm install in a fresh project during verification for this article), and the package is MIT-licensed per the xyflow GitHub repository.
Scaffold a minimal Vite + React project, then add the library:
npm create vite@latest agent-builder -- --template react cd agent-builder npm install @xyflow/react
React Flow also ships a stylesheet that has to be imported once, or the canvas renders with no layout at all:
import '@xyflow/react/dist/style.css';
Configure: the canvas skeleton
A React Flow canvas needs three things: an array of nodes, an array of edges, and the <ReactFlow> component itself. The official documentation's own minimal example is this shape:
import { ReactFlow, Background, Controls } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; const initialNodes = [ { id: 'n1', position: { x: 0, y: 0 }, data: { label: 'Node 1' }, type: 'input' }, { id: 'n2', position: { x: 100, y: 100 }, data: { label: 'Node 2' }, type: 'output' }, ]; const initialEdges = [ { id: 'n1-n2', source: 'n1', target: 'n2', type: 'smoothstep', label: 'connects with' }, ]; export default function App() { return ( <div style={{ height: '100%', width: '100%' }}> <ReactFlow nodes={initialNodes} edges={initialEdges}> <Background /> <Controls /> </ReactFlow> </div> ); }
That version is static — the arrays never change once rendered. A real builder needs the canvas to react to drags and new connections, which is what useNodesState and useEdgesState are for. Each hook returns the current array, a setter, and a change handler that wires directly into <ReactFlow>'s onNodesChange / onEdgesChange props:
import { ReactFlow, useNodesState, useEdgesState } from '@xyflow/react'; export default function App() { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); return ( <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} /> ); }
The React Flow documentation itself notes that these hooks are meant for prototyping a controlled flow, and that a larger builder should graduate to a dedicated state store (it specifically calls out Zustand) once node and edge state needs to be shared outside the canvas component — which a real execution engine does, since it has to read the same graph the canvas is displaying.
Basic implementation: a node type for an agent action
The default node types (input, default, output) only render a label. An agent workflow needs node types that carry configuration — a URL for a fetch step, a prompt template for a model step — and expose connection points other nodes can attach to. React Flow's custom-node pattern is a plain React component registered in a nodeTypes map:
const nodeTypes = { crawlNode: CrawlNode, promptNode: PromptNode, }; <ReactFlow nodeTypes={nodeTypes} nodes={nodes} edges={edges} />
Each custom node reads its own configuration from data and declares where edges can attach using the Handle component:
import { Handle, Position } from '@xyflow/react'; export function CrawlNode({ data }) { return ( <div className="workflow-node"> <div className="workflow-node__title">Fetch page</div> <div className="workflow-node__field">{data.url}</div> <Handle type="target" position={Position.Left} /> <Handle type="source" position={Position.Right} /> </div> ); }
A node with both a target handle (left) and a source handle (right) can sit in the middle of a chain — it receives a connection from an upstream node and sends its own output downstream. A trigger node would only need a source handle; a terminal output node would only need a target handle.
Advanced patterns: an execution engine that actually runs the graph
The canvas only produces two arrays — nodes and edges. Turning that into a running workflow means answering one question the UI library doesn't answer: what order do the nodes run in? The answer is a topological sort — visit every node only after every node that feeds into it has already run. This is graph theory, not React, so it was written and executed as a plain script for this article rather than left as a diagram.
The graph tested: a trigger node, a crawl node that fetches a page, a prompt node that summarizes what the crawl node returned, and an output node.
function topoSort(nodeList, edgeList) { const indegree = new Map(nodeList.map((n) => [n.id, 0])); const adjacency = new Map(nodeList.map((n) => [n.id, []])); for (const edge of edgeList) { adjacency.get(edge.source).push(edge.target); indegree.set(edge.target, indegree.get(edge.target) + 1); } const queue = nodeList.filter((n) => indegree.get(n.id) === 0).map((n) => n.id); const order = []; while (queue.length) { const id = queue.shift(); order.push(id); for (const next of adjacency.get(id)) { indegree.set(next, indegree.get(next) - 1); if (indegree.get(next) === 0) queue.push(next); } } if (order.length !== nodeList.length) { throw new Error('Graph has a cycle — a workflow must be a DAG'); } return order; }
The cycle check matters more than it looks: a canvas UI will happily let someone drag an edge that creates a loop (node A feeds node B feeds node A), and without this check the engine would either hang or silently drop nodes instead of telling the person who built the workflow what's wrong.
With an execution order in hand, running the workflow is a loop that calls each node's handler and threads outputs into inputs along the edges that point at it:
async function run(nodes, edges) { const order = topoSort(nodes, edges); const nodesById = new Map(nodes.map((n) => [n.id, n])); const outputs = new Map(); const log = []; for (const id of order) { const node = nodesById.get(id); const incoming = edges.filter((e) => e.target === id).map((e) => outputs.get(e.source)); let result; switch (node.type) { case 'trigger': result = 'run-started'; break; case 'crawl': result = await runCrawlNode(node); break; case 'prompt': result = runPromptNode(node, incoming[0]); break; case 'output': result = incoming[0]; break; default: throw new Error(`Unknown node type: ${node.type}`); } outputs.set(id, result); log.push({ node: id, type: node.type, output: result }); } return { order, log }; }
runCrawlNode is where a hand-rolled fetch() call to an arbitrary URL runs into trouble: plenty of real pages are rendered client-side, block requests without a browser fingerprint, or need a proxy to reach reliably at all. For this article's verification run, that handler called Nstproxy Crawl's POST /api/v1/crawl/scrape endpoint (documented at docs.nstproxy.com/docs/crawl) — which returns rendered Markdown instead of raw HTML — so the crawl node's job was reduced to unwrapping the response rather than reimplementing a browser:
async function runCrawlNode(node) { const response = await fetch('https://api.nstproxy.com/api/v1/crawl/scrape', { method: 'POST', headers: { 'x-api-key': process.env.NSTPROXY_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ url: node.data.url, formats: ['markdown'], onlyMainContent: true }), }); const envelope = await response.json(); if (envelope.err) throw new Error(envelope.msg || 'crawl request failed'); const inner = envelope.data; if (!inner.success) throw new Error(inner.status || 'crawl did not complete'); return inner.data.markdown; }
This environment has no outbound network access to arbitrary domains and no live Nstproxy API key, so the verification run below substitutes a local HTTP fixture that returns the exact nested response envelope Nstproxy Crawl's own documentation specifies (outer code/err/msg/data, inner data.success/status, page payload at data.data.markdown) in place of the real endpoint — this is disclosed here rather than presented as a live API call. The unwrapping logic itself (checking err, then success, then reading data.data.markdown) is exactly what runs against the real endpoint; only the transport target changed for this test.
Worked example: running the four-node graph
The graph used for this run: start (trigger) → fetch (crawl node targeting a Nstproxy Crawl product page) → prompt (summarizes whatever the crawl node returned) → output. It was executed with Node.js (v22.22.2) in this environment, with fetch pointed at a local fixture server standing in for the real Nstproxy Crawl API for the reason explained above.
Captured output:
{ "order": ["start", "fetch", "prompt", "output"], "log": [ { "node": "start", "type": "trigger", "output": "run-started" }, { "node": "fetch", "type": "crawl", "output": "# Nstproxy Crawl\n\nNstproxy Crawl turns a URL into clean Markdown, JSON, or a screenshot with a single API call, with JavaScript rendering and proxy-backed access built in." }, { "node": "prompt", "type": "prompt", "output": "Summarize this page in one sentence:\n\n# Nstproxy Crawl\n\nNstproxy Crawl turns a URL into clean Markdown, JSON, or a screenshot with a single API call, with JavaScript rendering and proxy-backed access built in.\n\n[mock model output] Nstproxy Crawl — one-line summary generated from the fetched page." }, { "node": "output", "type": "output", "output": "Summarize this page in one sentence:\n\n# Nstproxy Crawl\n\nNstproxy Crawl turns a URL into clean Markdown, JSON, or a screenshot with a single API call, with JavaScript rendering and proxy-backed access built in.\n\n[mock model output] Nstproxy Crawl — one-line summary generated from the fetched page." } ] }
The order array confirms the topological sort placed every node after its dependencies, and the log array shows each node's output flowing into the next — the crawl node's Markdown became the prompt node's input, and the prompt node's (mocked) summary became the final output. The prompt node's model call is a deterministic stand-in rather than a real LLM API call, disclosed the same way as the crawl substitution above; swapping in a real model call means calling whichever provider's SDK inside runPromptNode in place of the mock line.
Take a Quick Look
Every "fetch" node in a workflow like this eventually hits a page that needs JavaScript rendering or blocks a plain HTTP request — Nstproxy Crawl handles that layer with one API call instead of a hand-rolled browser.
Honest limits
A weekend build of a canvas plus a topological-sort engine proves the core pattern works, but it is missing everything a production workflow tool needs around that core. There is no persistence layer — the graph in this article lives in memory for one run and nothing is saved between sessions. There is no retry or partial-failure handling — if the crawl node's request fails, the whole run throws rather than retrying or routing to an error branch. There is no per-node execution log surfaced back to the canvas UI, no way to pause and inspect state mid-run, no authentication or multi-user access control, and no versioning of a saved workflow. There is also no protection against a node handler that runs arbitrary user-supplied code safely — any "custom code" node type in a real product needs a sandboxed execution environment, which this article's plain switch statement does not provide. None of these are React Flow's job to solve; they are the actual engineering work of turning this pattern into a tool other people can rely on. If a crawl node's data-fetching layer is the piece being outsourced rather than built by hand, it's worth checking a hosted API's pricing against expected call volume before committing a workflow's fetch step to it.
Troubleshooting
The canvas renders with no styling or overlapping nodes. This almost always means the @xyflow/react/dist/style.css import was skipped — React Flow positions nodes with absolute coordinates that depend on its own base stylesheet.
A workflow with a loop hangs or silently drops nodes. This is the cycle case the topoSort function above throws on — a canvas UI has no built-in way to prevent someone from wiring an edge that creates a cycle, so the engine has to check for it explicitly rather than assume every graph a user builds is a valid DAG.
A custom node doesn't accept connections. Check that the node component includes a Handle with the right type (source or target) and that it's actually rendered — a custom node component that omits <Handle> renders fine visually but can never be wired to another node.
A "fetch" node works for some URLs and fails for others. That's usually a JavaScript-rendering or bot-detection issue, not a bug in the node's code — a plain fetch() call only sees the raw HTML a server returns, not what a browser would render after running the page's scripts, which is why the crawl node in this article calls a rendering-aware API instead of fetching the URL directly.
Conclusion
Building an AI agent workflow builder splits cleanly into two verified pieces: React Flow (@xyflow/react, version 12.11.3, MIT-licensed) for the canvas, and a small hand-written topological-sort engine for execution — neither one depends on the other, which is why they can be developed and tested separately, as this article did. The result is a real, runnable pattern rather than a diagram, proven with an actual four-node graph executed end to end. What it isn't is a finished product — persistence, retries, sandboxing, and access control are still real engineering work layered on top of this core. For background on the data-fetching layer used in the worked example, see the Nstproxy Crawl launch post.
FAQ
Q: Do I need React Flow specifically, or can I use another library?
React Flow (@xyflow/react) is the library used and verified in this article, but it's not the only option — Svelte Flow (the same team's Svelte equivalent) covers non-React projects, and any canvas library that exposes node positions, edges, and a way to register custom components can fill the same role. The execution engine described here is completely independent of which canvas library renders it, since it only consumes plain node and edge arrays.
Q: Does the execution engine need to run in the browser?
No — and for most real workflows it shouldn't. The canvas runs in the browser so a person can edit the graph, but executing a workflow (especially one with API keys or long-running steps) belongs on a server. The engine shown in this article is plain JavaScript with no browser dependency, so it can run in Node.js exactly as tested here, or inside any backend runtime that supports fetch.
Q: How do I stop someone from wiring a workflow that loops forever?
Reject it before running it. The topoSort function in this article throws when the sorted order doesn't include every node, which is exactly what happens when a cycle exists — catch that error in the canvas UI and tell the person which nodes are involved, rather than letting the engine hang.
Q: Can a node call more than one external service?
Yes — a node handler is just a function; it can make as many calls as it needs before returning its output. The one constraint the engine imposes is that a node's handler only receives the outputs of the nodes with an edge pointing into it, so any node that needs data from two upstream sources needs two incoming edges.
Q: What's the fastest way to add a real language model call in place of the mock prompt node?
Replace the body of runPromptNode with a call to whichever model provider's SDK is in use, passing the incoming node's output as context and returning the model's response as a string — the rest of the engine (sorting, output-passing, logging) doesn't need to change, since it only cares that a node handler returns a value.
Q: Why does the crawl node call an API instead of just using fetch() directly?
A plain fetch() call only receives whatever HTML a server sends before any client-side JavaScript runs, and many pages render their actual content afterward — a rendering-aware API executes the page like a browser would before handing back Markdown or HTML, which is what the crawl node in this article's worked example relies on.



