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
- Root Node
- Response Node
- Calendar Node
- Farewell Node
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 = 1to make it a starting point - Use
match_any_inputfor default welcome message - Define trigger keywords for explicit activation
Standard message node with options:
[
'name' => 'Servicios',
'message_text' => 'Estos son nuestros servicios:\n- Consulta\n- Seguimiento',
'options' => [
[
'option_text' => '1. Agendar cita',
'option_keywords' => ['1', 'agendar', 'cita'],
'next_node_id' => 3
]
]
]
- Send message and wait for user input
- Match options by keywords
- Route to next node
Triggers calendar integration:
[
'name' => 'Agendar cita',
'requires_calendar' => true,
'message_text' => 'Voy a ayudarte a agendar tu cita. ¿Qué día prefieres?',
'trigger_keywords' => ['agendar', 'cita', 'appointment']
]
- Set
requires_calendar = 1 - System hands off to calendar flow
- Detects intent from node keywords
Ends the conversation:
[
'name' => 'Despedida',
'is_farewell' => true,
'message_text' => '¡Hasta pronto! Escribe cuando necesites.',
'trigger_keywords' => ['adios', 'chao', 'bye']
]
- Set
is_farewell = 1 - Clears user session
- No further navigation
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
);
Example Flow
Here’s the structure fromdatabase/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..."
}
]
}
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_farewellto properly end conversations
Next Steps
Create Flows
Build your first conversation flow
Conversation Management
Monitor and manage active conversations