Skip to main content

Overview

The Flow Builder enables you to create structured, rule-based conversation flows for Classic Bot mode. Design custom user journeys with nodes, options, and keyword triggers without writing code.
Flow Builder is only active when the bot is in Classic Mode. In AI Mode, the system uses RAG and OpenAI for responses.

Architecture

Flows consist of two core components:

Nodes

Individual conversation steps with messages and routing logic

Options

User choices that navigate between nodes based on keywords

Database Schema

CREATE TABLE flow_nodes (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    trigger_keywords TEXT,           -- JSON array of trigger words
    message_text TEXT NOT NULL,       -- Message sent to user
    next_node_id INT,                 -- Auto-advance to this node
    is_root TINYINT DEFAULT 0,        -- Entry point node
    requires_calendar TINYINT DEFAULT 0,  -- Trigger calendar flow
    match_any_input TINYINT DEFAULT 0,    -- Catch-all node
    is_farewell TINYINT DEFAULT 0,    -- End conversation
    position_order INT DEFAULT 0,     -- Display order
    is_active TINYINT DEFAULT 1
);

CREATE TABLE flow_options (
    id INT PRIMARY KEY AUTO_INCREMENT,
    node_id INT NOT NULL,
    option_text VARCHAR(255) NOT NULL,  -- Display text
    option_keywords TEXT,               -- JSON array of keywords
    next_node_id INT,                   -- Target node
    position_order INT DEFAULT 0,
    FOREIGN KEY (node_id) REFERENCES flow_nodes(id)
);

Flow Service

Manages flow data and validation:

Load Flow Tree

src/Services/FlowBuilderService.php
public function getFlowTree(): array
{
    $nodes = $this->db->fetchAll(
        "SELECT * FROM flow_nodes ORDER BY position_order ASC, id ASC",
        []
    ) ?? [];

    $options = $this->db->fetchAll(
        "SELECT * FROM flow_options ORDER BY node_id ASC, position_order ASC",
        []
    ) ?? [];

    $optionsByNode = [];
    foreach ($options as $opt) {
        $optionsByNode[(int)$opt['node_id']][] = $opt;
    }

    foreach ($nodes as &$node) {
        $node['options'] = $optionsByNode[(int)$node['id']] ?? [];
        $node['trigger_keywords'] = json_decode($node['trigger_keywords'], true) ?? [];
    }
    unset($node);

    return $nodes;
}

Save Node

src/Services/FlowBuilderService.php
public function saveNode(array $data): int
{
    $this->validateNodeData($data);

    $nextNodeId = isset($data['next_node_id']) ? (int)$data['next_node_id'] : null;

    // Detect infinite loops
    if (isset($data['id']) && $nextNodeId) {
        if ($this->detectCycle((int)$data['id'], $nextNodeId)) {
            throw new \InvalidArgumentException('El nodo destino crearía un ciclo infinito.');
        }
    }

    $nodeFields = [
        'name'              => $data['name'],
        'trigger_keywords'  => json_encode($data['trigger_keywords'] ?? []),
        'message_text'      => $data['message_text'],
        'next_node_id'      => $nextNodeId,
        'is_root'           => (int)($data['is_root'] ?? false),
        'requires_calendar' => (int)($data['requires_calendar'] ?? false),
        'match_any_input'   => (int)($data['match_any_input'] ?? false),
        'is_farewell'       => (int)($data['is_farewell'] ?? false),
        'position_order'    => (int)($data['position_order'] ?? 0),
        'is_active'         => (int)($data['is_active'] ?? true),
    ];

    if (isset($data['id']) && $data['id']) {
        $nodeId = (int)$data['id'];
        $this->db->update('flow_nodes', $nodeFields, 'id = :id', [':id' => $nodeId]);
    } else {
        $nodeId = $this->db->insert('flow_nodes', $nodeFields);
    }

    // Update options
    $this->db->query("DELETE FROM flow_options WHERE node_id = :node_id", [':node_id' => $nodeId]);

    foreach ($data['options'] ?? [] as $i => $opt) {
        $optNextNodeId = isset($opt['next_node_id']) ? (int)$opt['next_node_id'] : null;

        if ($optNextNodeId && $this->detectCycle($nodeId, $optNextNodeId)) {
            throw new \InvalidArgumentException(
                "La opción \"{$opt['option_text']}\" crearía un ciclo infinito."
            );
        }

        $this->db->insert('flow_options', [
            'node_id'         => $nodeId,
            'option_text'     => $opt['option_text'],
            'option_keywords' => json_encode($opt['option_keywords'] ?? []),
            'next_node_id'    => $optNextNodeId,
            'position_order'  => (int)($opt['position_order'] ?? $i),
        ]);
    }

    $this->logger->info('FlowBuilder: node saved', ['node_id' => $nodeId]);
    return $nodeId;
}

Cycle Detection

Prevents infinite loops in flow routing:
src/Services/FlowBuilderService.php
public function detectCycle(int $startId, int $targetId): bool
{
    if ($startId === $targetId) {
        return true;
    }

    $visited = [];
    $stack   = [$targetId];

    while (!empty($stack)) {
        $current = array_pop($stack);

        if ($current === $startId) {
            return true;  // Cycle detected
        }

        if (isset($visited[$current])) {
            continue;
        }
        $visited[$current] = true;

        $nodeRow = $this->db->fetchOne(
            "SELECT next_node_id FROM flow_nodes WHERE id = :id",
            [':id' => $current]
        );
        if ($nodeRow && $nodeRow['next_node_id']) {
            $stack[] = (int)$nodeRow['next_node_id'];
        }
    }

    return false;
}

Classic Bot Service

Executes flows during conversations:

Message Processing

src/Services/ClassicBotService.php
public function processMessage(string $userMessage, string $userPhone): array
{
    $fallback = $this->getFallbackMessage();
    $session = $this->getSession($userPhone);

    // Check for expired session
    if ($session && $this->isExpired($session['expires_at'])) {
        $this->clearSession($userPhone);
        $session = null;
    }

    $messageLower = mb_strtolower(trim($userMessage));

    // If user is in a flow
    if ($session && $session['current_node_id']) {
        $node = $this->getNode((int)$session['current_node_id']);

        if ($node) {
            // Try to match options
            $matchedOption = $this->matchOptions((int)$node['id'], $messageLower);

            if ($matchedOption) {
                return $this->resolveNextNode($matchedOption['next_node_id'], $userPhone, $fallback);
            }

            // Try to match node keywords
            if ($this->matchKeywords($node['trigger_keywords'], $messageLower)) {
                return $this->resolveNextNode($node['next_node_id'], $userPhone, $fallback);
            }

            // Increment failed attempts
            $newAttempts = (int)$session['attempts'] + 1;

            if ($newAttempts >= self::MAX_ATTEMPTS) {  // MAX_ATTEMPTS = 3
                $this->clearSession($userPhone);
                return ['type' => 'fallback', 'response' => $fallback];
            }

            $this->updateAttempts($userPhone, $newAttempts);
            return ['type' => 'fallback', 'response' => $fallback];
        }

        $this->clearSession($userPhone);
    }

    // No active session - check root nodes
    $rootNodes = $this->getRootNodes();

    foreach ($rootNodes as $rootNode) {
        if ($this->matchKeywords($rootNode['trigger_keywords'], $messageLower)) {
            return $this->resolveNextNode((int)$rootNode['id'], $userPhone, $fallback);
        }
    }

    // Check for catch-all node (match_any_input)
    $matchAnyNodes = array_filter($rootNodes, function ($n) {
        return !empty($n['match_any_input']);
    });

    if (!empty($matchAnyNodes)) {
        $this->logger->info('ClassicBot: match_any_input triggered');
        return $this->resolveNextNode((int)$matchAnyNodes[0]['id'], $userPhone, $fallback);
    }

    return ['type' => 'fallback', 'response' => $fallback];
}

Node Resolution

src/Services/ClassicBotService.php
private function resolveNextNode(?int $nodeId, string $userPhone, string $fallback): array
{
    if (!$nodeId) {
        $this->clearSession($userPhone);
        return ['type' => 'fallback', 'response' => $fallback];
    }

    $node = $this->getNode($nodeId);
    if (!$node) {
        $this->clearSession($userPhone);
        return ['type' => 'fallback', 'response' => $fallback];
    }

    // Handle special node types
    if ($node['requires_calendar']) {
        $this->clearSession($userPhone);
        return [
            'type'             => 'calendar',
            'response'         => $node['message_text'],
            'calendar_intent'  => $this->detectCalendarIntent($node),
        ];
    }

    if (!empty($node['is_farewell'])) {
        $this->clearSession($userPhone);
        return ['type' => 'farewell', 'response' => $node['message_text']];
    }

    // Save session and send message
    $this->saveSession($userPhone, $nodeId);
    return ['type' => 'response', 'response' => $node['message_text']];
}

Keyword Matching

src/Services/ClassicBotService.php
private function matchKeywords(string $keywordsJson, string $messageLower): bool
{
    $keywords = json_decode($keywordsJson, true) ?? [];
    foreach ($keywords as $kw) {
        if (strpos($messageLower, mb_strtolower(trim($kw))) !== false) {
            return true;
        }
    }
    return false;
}

Node Types

Entry point for conversations:
[
    'is_root' => true,
    'match_any_input' => true,  // Optional: catch all messages
    'trigger_keywords' => ['hola', 'inicio', 'menu'],
    'message_text' => 'Bienvenido. ¿En qué puedo ayudarte?\n\n1. Opción 1\n2. Opción 2'
]
  • Set is_root = 1 to make it a starting point
  • Use match_any_input for default welcome message
  • Define trigger keywords for explicit activation

Session Management

Classic bot maintains user sessions:
CREATE TABLE classic_flow_sessions (
    user_phone VARCHAR(20) PRIMARY KEY,
    current_node_id INT NOT NULL,
    attempts INT DEFAULT 0,
    expires_at DATETIME NOT NULL
);
Session TTL: 30 minutes Max attempts: 3 failed keyword matches before fallback

Example Flow

Here’s the structure from database/example-flow.json:
database/example-flow.json
{
  "nodes": [
    {
      "id": 1,
      "name": "Bienvenida",
      "is_root": true,
      "match_any_input": true,
      "message_text": "Bienvenido. ¿En qué puedo ayudarte?\n\n1. Agendar una cita\n2. Ver mis citas\n3. Información\n4. Hablar con un agente",
      "options": [
        {
          "option_text": "1. Agendar una cita",
          "option_keywords": ["1", "agendar", "cita"],
          "next_node_id": 2
        },
        {
          "option_text": "2. Ver mis citas",
          "option_keywords": ["2", "ver citas", "mis citas"],
          "next_node_id": 6
        }
      ]
    },
    {
      "id": 2,
      "name": "Agendar cita",
      "requires_calendar": true,
      "trigger_keywords": ["agendar", "nueva cita"],
      "message_text": "Perfecto, vamos a agendar tu cita. ¿Qué día prefieres?"
    },
    {
      "id": 6,
      "name": "Ver citas próximas",
      "requires_calendar": true,
      "trigger_keywords": ["ver citas", "mis citas"],
      "message_text": "Consultando tus próximas citas..."
    }
  ]
}
1

User sends message

“Hola” matches root node’s match_any_input
2

Bot sends welcome

Node 1 message with options displayed
3

User chooses option

“1” matches first option’s keywords
4

Navigate to node 2

Calendar node activated
5

Calendar flow starts

System hands off to calendar scheduling

Import/Export

Backup and restore flows as JSON:

Export

src/Services/FlowBuilderService.php
public function exportToJson(): string
{
    return json_encode([
        'version'    => '1.0',
        'exported_at' => date('c'),
        'nodes'      => $this->getFlowTree(),
    ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}

Import

src/Services/FlowBuilderService.php
public function importFromJson(string $json): array
{
    $data = json_decode($json, true);
    if (!isset($data['nodes']) || !is_array($data['nodes'])) {
        throw new \InvalidArgumentException('JSON inválido: falta array "nodes"');
    }

    $this->db->beginTransaction();
    try {
        // Clear existing flow
        $this->db->query("DELETE FROM flow_options", []);
        $this->db->query("DELETE FROM flow_nodes",   []);

        $oldToNew = [];

        // Insert nodes
        foreach ($data['nodes'] as $node) {
            $oldId = (int)$node['id'];
            $newId = $this->db->insert('flow_nodes', [
                'name'              => $node['name'],
                'trigger_keywords'  => json_encode($node['trigger_keywords'] ?? []),
                'message_text'      => $node['message_text'],
                'is_root'           => (int)($node['is_root'] ?? false),
                'requires_calendar' => (int)($node['requires_calendar'] ?? false),
                'match_any_input'   => (int)($node['match_any_input'] ?? false),
                'is_farewell'       => (int)($node['is_farewell'] ?? false),
                'is_active'         => (int)($node['is_active'] ?? true),
            ]);
            $oldToNew[$oldId] = $newId;
        }

        // Update relationships
        foreach ($data['nodes'] as $node) {
            $oldId = (int)$node['id'];
            $newId = $oldToNew[$oldId];

            if (!empty($node['next_node_id'])) {
                $newNext = $oldToNew[(int)$node['next_node_id']] ?? null;
                if ($newNext) {
                    $this->db->query(
                        "UPDATE flow_nodes SET next_node_id = :next WHERE id = :id",
                        [':next' => $newNext, ':id' => $newId]
                    );
                }
            }

            // Insert options
            foreach ($node['options'] ?? [] as $opt) {
                $optNext = !empty($opt['next_node_id']) 
                    ? ($oldToNew[(int)$opt['next_node_id']] ?? null) 
                    : null;
                $this->db->insert('flow_options', [
                    'node_id'         => $newId,
                    'option_text'     => $opt['option_text'],
                    'option_keywords' => json_encode($opt['option_keywords'] ?? []),
                    'next_node_id'    => $optNext,
                ]);
            }
        }

        $this->db->commit();
        return ['imported_nodes' => count($data['nodes']), 'id_map' => $oldToNew];
    } catch (\Throwable $e) {
        $this->db->rollback();
        throw $e;
    }
}

Best Practices

Clear Keywords

Use specific, non-overlapping keywords for reliable matching

Fallback Messages

Configure helpful fallback for unmatched input

Session Timeout

30-minute TTL balances UX and resource usage

Test Cycles

System prevents infinite loops automatically
Important considerations:
  • Keep flows simple and linear when possible
  • Test keyword matching with real user input
  • Provide “back to menu” options in each node
  • Use is_farewell to properly end conversations

Next Steps

Create Flows

Build your first conversation flow

Conversation Management

Monitor and manage active conversations

Build docs developers (and LLMs) love