DEV Community

Cover image for Building a Visual Chatbot Engine for WhatsApp: Nodes, Edges, Delays and Timers
WAPGROWAI for WAPGROWAI

Posted on

Building a Visual Chatbot Engine for WhatsApp: Nodes, Edges, Delays and Timers

A WhatsApp chatbot builder looks like a drawing tool: drag boxes onto a canvas, connect them with arrows, press save. Behind it sits an engine that has to turn that drawing into a conversation that can pause for hours, survive restarts, cope with customers who don't tap the buttons, and hand over to a person at the right moment. This post walks through how we built that engine for WAPGROWAI: how the graph is stored, how a session moves through it, and how delays and timeouts work.

The snippets are simplified versions of the patterns in our engine, and each was run against the cases shown.

The WAPGROWAI chatbot builder: a block library on the left and a flow of Message, Question and Assign Team nodes on the canvas
The builder: blocks on the left, the flow on the canvas. Every arrow is a row in the edges table.

The graph: three tables and one contract

A flow is stored as three tables: flows, nodes and edges. Each node has a type (Message, Question, Condition, Time Delay, Assign Team, WhatsApp Flow, Webhook, payment requests and so on) plus the settings for that type. Each edge connects a source node to a target node.

The interesting column is on the edge. A node with several exits (a condition with rules, a webhook that branches on the HTTP status, a payment that ends paid or expired) needs to know which arrow is which. On the canvas, each exit is a handle; in the database, the edge stores that handle's value. That single field is the contract between the builder and the engine:

// graph.ts
export type Edge = { source: string; target: string; handle: string | null; order: number };

// The edge's handle is the branch it belongs to: a condition rule index ("0", "1"),
// an HTTP status ("200"), "paid" / "expired", or "default" for the fallthrough.
export const nextNode = (edges: Edge[], from: string, branch?: string): string | null => {
  const outgoing = edges.filter((e) => e.source === from).sort((a, b) => a.order - b.order);
  if (branch !== undefined) {
    const exact = outgoing.find((e) => e.handle === branch);
    if (exact) return exact.target;
  }
  const fallthrough = outgoing.find((e) => e.handle === null || e.handle === "default");
  return (fallthrough ?? (branch === undefined ? outgoing[0] : undefined))?.target ?? null;
};
Enter fullscreen mode Exit fullscreen mode
  • Exact branch first, then the default exit. A condition that matches rule 1 follows the "1" edge; one that matches nothing follows default.
  • No edge means the flow is finished. There's no "End" block. When a node has nowhere to go, the session completes and the conversation returns to the team inbox.
  • The builder and the engine must agree on handle ids. Rename a handle in the UI without migrating stored edges, and the arrow still draws but the engine can't follow it. Keep the mapping in one shared place.

Sessions: where the conversation is

Each conversation has one session row: which flow it's in, the current node, the variables collected so far, and a status (ACTIVE, WAITING, COMPLETED, HANDED_OFF, EXPIRED, ERROR).

Running a flow is a short loop. Execute the current node; if it doesn't need anything from the customer (send a message, update an attribute, assign a team), move to the next node and execute that. When a node does need the customer (a question, a message with buttons, a WhatsApp Flow form), send it and stop. The session stays on that node, and the next inbound message picks up from there.

The live preview of a lead qualification bot: two typed answers, then a question with a list of options
preview: four options, so the question goes out as a list behind a View Options button.*

When the customer doesn't tap a button

Real customers type "the second one", "hi??" or a voice note instead of tapping. Every question node needs a policy for that, and we settled on a small budget:

// reply.ts
export type ReplyResult =
  | { kind: "matched"; option: string }
  | { kind: "reprompt"; attempts: number }
  | { kind: "handoff" };

const normalise = (s: string) => s.trim().toLowerCase();

// A tapped button arrives as its title; a typed answer may match one too.
// Anything else counts against a small budget before a person takes over.
export const matchReply = (text: string, options: string[], attempts: number, maxAttempts = 3): ReplyResult => {
  const option = options.find((o) => normalise(o) === normalise(text));
  if (option) return { kind: "matched", option };
  const next = attempts + 1;
  return next >= maxAttempts ? { kind: "handoff" } : { kind: "reprompt", attempts: next };
};
Enter fullscreen mode Exit fullscreen mode
  • Typed answers that match an option count. "support" works as well as tapping Support.
  • Unrecognised replies re-prompt, then hand off. In WAPGROWAI the business sets the fallback message and the budget (three by default). When it runs out, the bot stops and the chat goes to the team inbox. A bot that asks the same question forever is the fastest way to lose a customer.
  • Let the flow designer catch the rest. If the block after a question is a condition with an "else" branch, we pass the unrecognised answer down that branch instead of re-prompting. That lets a builder route "anything else" to a person on purpose.

Fitting WhatsApp's interactive limits

WhatsApp's reply buttons allow up to three buttons with 20-character titles. List messages allow up to ten rows with 24-character titles, behind a single button. Reply-button bodies are capped at 1,024 characters (lists allow more; we use the lower limit for both). The builder lets people write whatever they like, so the engine picks the right shape at send time:

// interactive.ts
// WhatsApp limits: up to 3 reply buttons (20-character titles); lists hold up to
// 10 rows (24-character titles) behind a button. Bodies: 1024 for buttons, used for both.
const clip = (s: string, max: number) => (s.length > max ? s.slice(0, max) : s);

export const toInteractive = (body: string, options: string[]) => {
  const ids = options.slice(0, 10).map((title, i) => ({ id: `opt_${i}`, title }));
  if (body.length > 1024) return { type: "text_then_choices" as const, body, options: ids };
  if (ids.length <= 3) {
    return {
      type: "button" as const,
      body: { text: body },
      action: { buttons: ids.map(({ id, title }) => ({ type: "reply", reply: { id, title: clip(title, 20) } })) },
    };
  }
  return {
    type: "list" as const,
    body: { text: body },
    action: { button: "View Options", sections: [{ rows: ids.map(({ id, title }) => ({ id, title: clip(title, 24) })) }] },
  };
};
Enter fullscreen mode Exit fullscreen mode

Clipping at send time keeps messages deliverable, but it's a last resort: a clipped title that no longer matches what the customer types is a matching problem waiting to happen. Show the limits in the builder too, so authors write titles that fit.

Interactive messages are non-template messages, so they only go out while the 24-hour customer service window is open. A bot normally runs in reply to the customer's own message, which opens the window. That's also why we cap a single delay at three hours.

Delays that survive a restart

"Wait 30 minutes, then send the brochure" is easy with setTimeout until you deploy, and every pending wait disappears. The database has to be the source of truth:

// delay.ts
export type Session = { id: string; status: "ACTIVE" | "WAITING" | "COMPLETED"; nodeId: string; resumeAt?: number };

export interface SessionStore {
  save(session: Session): void;
  // UPDATE sessions SET status = 'ACTIVE' WHERE id = ? AND status = 'WAITING' AND resume_at <= ?
  claimIfDue(id: string, now: number): boolean;
  due(now: number, limit: number): Session[];
}

const IN_PROCESS_CAP_MS = 10 * 60 * 1000;

// The database row is the source of truth; the timer is only a shortcut.
export const startDelay = (store: SessionStore, session: Session, ms: number, now: number, resume: (id: string) => void) => {
  store.save({ ...session, status: "WAITING", resumeAt: now + ms });
  if (ms <= IN_PROCESS_CAP_MS) setTimeout(() => resume(session.id), ms);
};

// Called by both the timer and the scheduler. Only one of them gets true.
export const tryResume = (store: SessionStore, id: string, now: number) => store.claimIfDue(id, now);

export const schedulerTick = (store: SessionStore, now: number, advance: (id: string) => void) => {
  for (const session of store.due(now, 200)) {
    if (tryResume(store, session.id, now)) advance(session.id);
  }
};
Enter fullscreen mode Exit fullscreen mode
  • The row records the wait: status WAITING and a resume_at time.
  • Short waits also get an in-process timer, so a two-minute pause doesn't wait for the next scheduler tick.
  • A scheduler sweeps for overdue waits every 15 seconds. After a restart, it finds everything the lost timers would have resumed.
  • Resuming is a conditional UPDATE. Both the timer and the scheduler try; the one whose update changes a row continues the flow, and the other gets zero rows and stops. That's what prevents a step from running twice.

The same compare-and-set is worth applying to inbound replies too. Two quick messages from a customer can arrive while the session is still on the same node; making "advance from node X" conditional on the session still being at node X stops both from advancing it.

Idle timeouts and handing over

A conversation can also just stop. A second scheduler, once a minute, looks for active sessions where the customer hasn't replied within the business's chosen time. It can send one "are you still there?" message first, and when the time is up it ends the bot so the chat sits with the team instead of a bot that's waiting forever.

Bots also have to know when to step aside:

  • A person replying takes over. When an agent answers from the inbox, the bot session ends as handed off and the chat is assigned to that agent.
  • Assign Team routes the chat to a team and, in round-robin mode, to the member with the fewest open and pending chats.
  • A failed send stops the flow. If WhatsApp rejects a message, the session ends with an error rather than running later steps into a conversation the customer never saw.

WhatsApp's own policy requires businesses using automation to offer "prompt, clear, and direct escalation paths". A human handoff isn't a nice extra; it's part of running a bot on WhatsApp at all.

Who handles an inbound message?

A bot is one of several things that may want to answer. For each inbound message we decide in a fixed order: a submitted WhatsApp Flow form resumes its bot; a tapped template button runs its configured action; a keyword rule can start a chatbot (even mid-flow); an active bot continues; then the AI agent, if it's on; then the remaining rules. Writing this order down is the cheapest fix there is for "the customer got two replies".

Summary

  • Store flows as nodes and edges, and treat the edge's handle value as a contract between the builder and the engine.
  • Keep one session row per conversation; execute until a node needs the customer, then stop.
  • Give unrecognised replies a small budget, then hand the chat to a person.
  • Pick buttons or a list at send time to fit WhatsApp's limits, and show those limits in the builder.
  • Persist delays in the database, use timers only as a shortcut, and resume with a conditional update.
  • End idle sessions, hand over when a person replies, and decide in a fixed order who answers each message.

WAPGROWAI is built by DI Solutions, a custom software and AI development company. The chatbot builder and engine described here are part of the WAPGROWAI WhatsApp Business platform. Earlier in this series: webhooks, the 24-hour window, WhatsApp Flows and the AI agent.

Top comments (0)