← All articles

Compiling a Drag-and-Drop Flow Builder Into a Deployed Chatbot

By Shuvo Haldar5 min read
ChatbotsCompilersNode.jsArchitectureGraph Algorithms
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.

Animated graph traversal: pulse rings fire over an entry node, an LLM node, a branch node, and a payment node in visit order along their output-port edges; a dashed loop-back edge points at an already-seen node, and a side panel shows the seen set filling up so cycles return early instead of recursing forever.
The compiler walks output ports from the entry node; a 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.

Three-stage compiler diagram: parse turns the serialized graph into nodes keyed by id and edges by output port; analyze traverses in reachable order, guards cycles with a seen set, and collects payment nodes to provision; emit maps each node type to a bot instruction, packages the template, and deploys — every stage per-tenant scoped with Meta, Stripe, and S3 keyed per tenant.
Framing it as a compiler gives a clear contract — adding a node type touches one 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.

Building something similar?

I'm a senior full-stack & DevOps engineer available for full-time roles and projects worldwide.