Compiling a Drag-and-Drop Flow Builder Into a Deployed Chatbot
A drag-and-drop chatbot builder looks like a UI feature. It isn't. Under the pretty canvas is a compiler problem: a user wires together typed nodes — an LLM reply here, a Stripe payment there, an appointment booking, an API call, a conditional branch — and somehow that picture has to become a running bot that handles real conversations. At Peasy I built the backend that does this translation: it takes the visual flow graph a user drew and compiles it into a Botpress bot definition, then deploys it.
The front end is the easy half to demo and the back end is where the actual work is. A canvas that lets you drag boxes and draw arrows is a solved UI problem. Turning that serialized graph into something executable is not.
seen set stops the loops users draw from recursing forever.The flow is a graph, so treat it like one
What the front end hands me is nodes and edges. Each node has a type and a config; each edge connects a node's output port to the next node's input. That's a directed graph, and every hard question about it — "what runs after this?", "which nodes need a payment set up?", "is anything unreachable?" — is a graph traversal.
The mistake would be to walk the nodes in the order they appear in the array and emit code linearly. Flows branch. A conditional node has two output ports going to two different subtrees; a node can be the target of several edges. You have to follow the ports, not the list order.
// Walk from the entry node along its ports, compiling each node in reachable order.
function compile(graph: FlowGraph): BotDefinition {
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
const out = new BotBuilder();
const seen = new Set<string>();
(function visit(nodeId: string) {
if (seen.has(nodeId)) return; // graphs can loop back — don't recurse forever
seen.add(nodeId);
const node = byId.get(nodeId)!;
out.emit(compileNode(node)); // node type -> bot instruction
for (const edge of graph.edgesFrom(nodeId)) // follow every output port
visit(edge.to);
})(graph.entryNodeId);
return out.build();
}
The seen set is doing quiet, important work: user-built flows loop back on
themselves ("didn't understand, ask again"), and without cycle protection the
compiler recurses until it dies. Any real graph compiler has to assume the input
graph has cycles, because users draw them constantly.
Some nodes need setup found by traversing the graph
Here's a concrete example of why linear thinking fails. A flow with a payment node needs a Stripe payment link provisioned before the bot can run. But which nodes are payment nodes, and what do they connect to? You find them by traversing the graph and collecting nodes of that type along their linked ports — not by scanning a flat list, because a payment node's meaning depends on where it sits in the flow and what feeds into it. The compile step gathers those, provisions what each one needs, and packages it into the bot template before deploy.
This is the theme of the whole system: the graph's structure carries meaning, and the compiler's job is to extract that meaning, not just transcribe boxes.
Per-tenant isolation, because it's a shared compiler
This runs multi-tenant — many businesses building many bots on the same service — so every compile and deploy is scoped to one tenant, integrations (Meta Graph API, Stripe, S3) are keyed per tenant, and requests carry encrypted-header auth. One user's flow compilation can never reach into another's Stripe account or bot. When you're running everyone's bots through one compiler, isolation isn't a feature you add later; it's a property every stage has to preserve.
Why I think of it as a compiler, deliberately
Calling it a compiler isn't me being fancy — it changes how you build it, and for the
better. A compiler has a clear contract: a well-defined input (the serialized graph),
a well-defined output (the bot definition), and a transformation in between that you
can test in isolation. That framing is what let this stay maintainable as node types
grew. Adding a new node type means adding one compileNode case and its provisioning,
not touching the traversal. The graph walk, the cycle guard, the per-tenant scoping —
all of that is written once and never revisited.
If you build a visual builder as "a big function that reads the UI state," it rots fast. If you build it as parse → analyze → emit, it scales with the feature set.
emit case, never the traversal.Takeaways
- A visual flow builder is a compiler: serialized graph in, deployable artifact out. Design the backend around that, not around the canvas.
- Traverse ports, not array order — flows branch and merge — and always guard for cycles, because users draw loops.
- Some setup (payments, integrations) is discovered by walking the graph for nodes of a type; the structure carries the meaning.
- Keep every stage per-tenant scoped when the compiler is shared.
The canvas is the part users see. The compiler is the part that makes their drawing actually answer a customer at 2am.
I build backend, real-time, and platform systems end-to-end. If you're working on something similar, let's talk.