Files
helix-engage-server/docs/specs/2026-04-20-flow-runtime-design.md
saridsa2 4549241b78 docs: flow runtime design spec — config-driven WhatsApp conversation engine
Groups + Blocks model adapted from Typebot. Execution loop pauses at
InputBlocks, resumes on next message. Tool registry bridges existing
tools. Session state in Redis with 24h TTL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:18:17 +05:30

8.5 KiB

WhatsApp Flow Runtime — Design Spec

Goal

Config-driven conversation engine that reads flow definitions (JSON) and executes them at runtime. Replaces the hardcoded system prompt + tools in messaging.service.ts. Hospital admins define flows via API/file — no code changes needed.

Architecture

Inbound WhatsApp message
    → MessagingController (existing)
    → FlowExecutionService (NEW — replaces MessagingService AI logic)
        → Load/create FlowSession from Redis
        → Match flow by trigger (or resume existing session)
        → Walk forward through Groups → Blocks
        → Pause at InputBlock, resume on next message
        → Send messages via MessagingProvider (existing)
        → Call tools via ToolRegistry (NEW)
    → Reply sent to patient

Flow Definition Schema

type Flow = {
    id: string;
    name: string;
    description: string;
    trigger: FlowTrigger;
    groups: Group[];
    edges: Edge[];
    variables: VariableDefinition[];
    version: number;
    status: 'draft' | 'published';
};

type FlowTrigger =
    | { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
    | { type: 'default' };

type VariableDefinition = {
    id: string;
    name: string;
    type: 'string' | 'number' | 'boolean' | 'object' | 'array';
    defaultValue?: any;
};

Groups and Edges

type Group = {
    id: string;
    title: string;
    blocks: Block[];
};

type Edge = {
    id: string;
    from: { blockId: string; conditionId?: string };
    to: { groupId: string; blockId?: string };
};

Block Types

type Block =
    | MessageBlock
    | InputBlock
    | ConditionBlock
    | SetVariableBlock
    | ToolCallBlock
    | AIBlock
    | JumpBlock;

// Send text/list/buttons to patient
type MessageBlock = {
    id: string;
    type: 'message';
    content:
        | { format: 'text'; text: string }
        | { format: 'buttons'; text: string; buttons: { id: string; title: string }[] }
        | { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] };
};

// Wait for patient reply
type InputBlock = {
    id: string;
    type: 'input';
    inputType: 'text' | 'interactive_reply' | 'any';
    variableId: string;
    validation?: { regex?: string; errorMessage?: string };
};

// Branch based on variable value
type ConditionBlock = {
    id: string;
    type: 'condition';
    conditions: {
        id: string;
        variableId: string;
        operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
        value?: string;
    }[];
};

// Assign/transform a variable
type SetVariableBlock = {
    id: string;
    type: 'set_variable';
    variableId: string;
    value: string;
    expression?: 'extract_id';
};

// Execute a registered tool
type ToolCallBlock = {
    id: string;
    type: 'tool_call';
    toolName: string;
    inputs: Record<string, string>;  // values support {{variables}}
    outputVariableId?: string;
};

// Generate dynamic LLM response
type AIBlock = {
    id: string;
    type: 'ai';
    prompt: string;                  // supports {{variables}}
    outputVariableId?: string;
    sendToPatient: boolean;
};

// Jump to another group
type JumpBlock = {
    id: string;
    type: 'jump';
    targetGroupId: string;
};

Session State (Redis)

type FlowSession = {
    flowId: string;
    currentGroupId: string;
    currentBlockIndex: number;
    variables: Record<string, any>;
    history: ConversationEntry[];
    startedAt: number;
    lastActiveAt: number;
};

Key: wa:flow:{phone}, TTL: 24 hours (WhatsApp session window).

Execution Loop

On inbound message:
    1. Load session from Redis (or create new → match flow by trigger)
    2. If paused at InputBlock → store reply in variable, advance
    3. Walk forward:
        - MessageBlock → send via provider, advance
        - InputBlock → save session, STOP (wait for next message)
        - ConditionBlock → evaluate, follow matching edge (or fall through)
        - SetVariableBlock → assign value, advance
        - ToolCallBlock → execute tool, store result, advance
        - AIBlock → call LLM, optionally send, advance
        - JumpBlock → jump to target group
        - End of group → follow outgoing edge to next group
    4. If no more blocks/edges → flow complete, clear session

Tool Registry

Existing tools from messaging.service.ts become registered tools:

Tool Name Description Inputs Output
resolve_caller Phone → Lead + Patient phone { leadId, patientId, isNew, name }
send_department_list Send interactive department list (none — reads from platform) { departments[] }
send_doctor_list Send interactive doctor list department { doctors[] }
send_slot_list Send time slots for doctor+date doctorId, doctorName, date { slots[] }
send_confirm_buttons Send confirm/cancel buttons summary { sent: true }
book_appointment Book appointment (with conflict check) patientName, phoneNumber, department, doctorName, scheduledAt, reason { booked, appointmentId }
lookup_appointments Check existing appointments patientId? { appointments[] }
create_lead Create lead + patient name, phoneNumber, interest { leadId }

Example Flow: Appointment Booking

Group: "Greeting" (g1)
    → AIBlock: greet using patient name + context
    → MessageBlock: buttons ["Book Appointment", "Check Appointment", "Ask a Question"]
    → InputBlock: store in {{intent}}
    Edges: g1 → ConditionBlock routes to g2 (book) / g7 (check) / g8 (question)

Group: "Department Selection" (g2)
    → ToolCallBlock: send_department_list
    → InputBlock: store in {{selectedDepartment}}
    Edge: g2 → g3

Group: "Doctor Selection" (g3)
    → ToolCallBlock: send_doctor_list, input: department={{selectedDepartment}}
    → InputBlock: store in {{selectedDoctor}}
    → SetVariableBlock: extract doctorId from {{selectedDoctor}}
    Edge: g3 → g4

Group: "Date Selection" (g4)
    → MessageBlock: "When would you like to visit?"
    → MessageBlock: buttons ["Tomorrow", "Day After", "Choose Date"]
    → InputBlock: store in {{dateChoice}}
    → ConditionBlock: tomorrow → SetVariable, day_after → SetVariable, else → AI parse
    Edge: g4 → g5

Group: "Slot Selection" (g5)
    → ToolCallBlock: send_slot_list, inputs: doctorId={{doctorId}}, date={{selectedDate}}
    → InputBlock: store in {{selectedSlot}}
    Edge: g5 → g6

Group: "Confirmation" (g6)
    → MessageBlock: buttons ["Confirm", "Cancel"], summary text
    → InputBlock: store in {{confirmation}}
    → ConditionBlock: confirm → g7, cancel → g8
    Edges: confirm → "Booking" group, cancel → "Cancelled" group

Group: "Booking" (g7)
    → ToolCallBlock: book_appointment with all collected variables
    → MessageBlock: confirmation with reference number

Group: "Cancelled" (g8)
    → MessageBlock: "No problem! Let me know if you need anything else."

File Structure (Implementation)

src/messaging/
├── flow/
│   ├── flow-types.ts                — All types above
│   ├── flow-execution.service.ts    — Main execution loop
│   ├── flow-session.service.ts      — Redis session CRUD
│   ├── flow-store.service.ts        — Load/save flow definitions (file/Redis)
│   ├── flow-variable.service.ts     — Variable interpolation + expressions
│   ├── tool-registry.ts             — Tool name → handler mapping
│   └── default-flows/
│       └── appointment-booking.json — Seeded default flow
├── providers/  (existing, unchanged)
├── messaging.module.ts              — Wire new services
├── messaging.controller.ts          — Unchanged (webhook still here)
├── messaging.service.ts             — Delegates to FlowExecutionService
└── types.ts                         — Existing types (unchanged)

Migration Path

  1. Build FlowExecutionService alongside existing MessagingService
  2. Seed default appointment-booking.json (equivalent to current hardcoded flow)
  3. MessagingService checks: if flow config exists → delegate to FlowExecutionService, else → current AI behavior (backward compatible)
  4. Once validated, remove hardcoded AI flow from MessagingService

Not in Scope

  • Visual builder UI (future, maybe never)
  • Flow versioning/rollback (v2)
  • Flow analytics/metrics (v2)
  • Multi-flow routing (v2 — for now, one active flow per trigger type)