FlowEngine
A declarative flow engine for building rules-driven wizards and intake forms in pure Ruby. Define multi-step flows as directed graphs with conditional branching, evaluate transitions using an AST-based rule system, and collect structured answers through a stateful runtime engine — all without framework dependencies.
[!CAUTION] This is not a form builder. It's a Form Definition Engine that separates flow logic, data schema, and UI rendering into independent concerns.
Installation
gem "flowengine"
Quick Start
require "flowengine"
definition = FlowEngine.define do
start :name
step :name do
type :text
question "What is your name?"
transition to: :age
end
step :age do
type :number
question "How old are you?"
transition to: :beverage, if_rule: greater_than(:age, 20)
transition to: :thanks
end
step :beverage do
type :single_select
question "Pick a drink."
%w[Beer Wine Cocktail]
transition to: :thanks
end
step :thanks do
type :text
question "Thank you for your responses!"
end
end
engine = FlowEngine::Engine.new(definition)
engine.answer("Alice") # :name -> :age
engine.answer(25) # :age -> :beverage (25 > 20)
engine.answer("Wine") # :beverage -> :thanks
engine.answer("ok") # :thanks -> finished
engine.finished? # => true
engine.answers # => { name: "Alice", age: 25, beverage: "Wine", thanks: "ok" }
engine.history # => [:name, :age, :beverage, :thanks]
If Alice were 18, the engine skips :beverage entirely — the first matching transition (18 NOT > 20) falls through to the unconditional :thanks.
The DSL
Defining a Flow
Every flow starts with FlowEngine.define, which returns a frozen, immutable Definition:
definition = FlowEngine.define do
start :first_step
# Optional: one-shot LLM pre-fill (see "Introduction" section)
introduction label: "Describe your situation",
placeholder: "Type here...",
maxlength: 2000
step :first_step do
type :text
question "What is your name?"
transition to: :second_step
end
end
Step Configuration
| Method | Purpose | Example |
|---|---|---|
type |
Input type (for UI adapters) | :text, :number, :single_select, :multi_select, :number_matrix, :ai_intake |
question |
Prompt shown to the user | "What is your filing status?" |
options |
Available choices (select types) | %w[W2 1099 Business] |
fields |
Named fields (matrix types) | %w[RealEstate SCorp LLC] |
decorations |
Opaque UI metadata | { hint: "metadata" } |
transition |
Where to go next (with optional condition) | transition to: :next, if_rule: equals(:field, "val") |
visible_if |
Visibility rule (DAG mode) | visible_if contains(:income, "Rental") |
max_clarifications |
Max follow-up rounds for :ai_intake steps |
max_clarifications 3 |
Transitions
Evaluated in order — the first matching transition wins. A transition with no if_rule: always matches (use as fallback):
step :income_types do
type :multi_select
question "Select income types."
%w[W2 1099 Business Investment Rental]
transition to: :business_count, if_rule: contains(:income_types, "Business")
transition to: :investment_details, if_rule: contains(:income_types, "Investment")
transition to: :state_filing # unconditional fallback
end
Visibility Rules
Steps can have visibility conditions for DAG-mode rendering:
step :spouse_income do
type :number
question "What is your spouse's annual income?"
visible_if equals(:filing_status, "married_filing_jointly")
transition to: :deductions
end
Rule System
Rules are immutable AST objects — composable and evaluated polymorphically.
Atomic Rules
| Helper | Evaluates |
|---|---|
contains(:field, "val") |
Array(answers[:field]).include?("val") |
equals(:field, "val") |
answers[:field] == "val" |
greater_than(:field, 10) |
answers[:field].to_i > 10 |
less_than(:field, 5) |
answers[:field].to_i < 5 |
not_empty(:field) |
answers[:field] is not nil and not empty |
Composite Rules
# AND — all must be true
transition to: :special, if_rule: all(
equals(:status, "married"),
contains(:income, "Business"),
greater_than(:business_count, 2)
)
# OR — at least one must be true
transition to: :alt, if_rule: any(
contains(:income, "Investment"),
contains(:income, "Rental")
)
# Nest arbitrarily
transition to: :complex, if_rule: all(
equals(:status, "married"),
any(greater_than(:biz_count, 3), contains(:income, "Rental")),
not_empty(:dependents)
)
Engine API
engine = FlowEngine::Engine.new(definition)
| Method | Returns | Description |
|---|---|---|
current_step_id |
Symbol? |
Current step ID |
current_step |
Node? |
Current Node object |
answer(value) |
nil |
Records answer and advances |
finished? |
Boolean |
True when no more steps |
answers |
Hash |
All collected { step_id => value } |
history |
Array<Symbol> |
Visited step IDs in order |
definition |
Definition |
The immutable flow definition |
submit_introduction(text, llm_client:) |
nil |
One-shot LLM pre-fill from free-form text |
submit_ai_intake(text, llm_client:) |
ClarificationResult |
Multi-round AI intake for current :ai_intake step |
submit_clarification(text, llm_client:) |
ClarificationResult |
Continue an active AI intake conversation |
introduction_text |
String? |
Raw introduction text submitted |
clarification_round |
Integer |
Current AI intake round (0 if none active) |
conversation_history |
Array<Hash> |
AI intake conversation [{role:, text:}] |
to_state / .from_state |
Hash / Engine |
State serialization for persistence |
Error Handling
engine.answer("extra") # AlreadyFinishedError (flow finished)
definition.step(:nonexistent) # UnknownStepError
engine.submit_introduction("SSN: 123-45-6789", llm_client:) # SensitiveDataError
engine.submit_introduction("A" * 3000, llm_client:) # ValidationError (maxlength)
engine.submit_ai_intake("hi", llm_client:) # EngineError (not on an ai_intake step)
LLM Integration
FlowEngine offers two ways to use LLMs for pre-filling answers from free-form text.
LLM Adapters & Configuration
The gem ships with three adapters (all via ruby_llm):
| Adapter | Env Variable |
|---|---|
AnthropicAdapter |
ANTHROPIC_API_KEY |
OpenAIAdapter |
OPENAI_API_KEY |
GeminiAdapter |
GEMINI_API_KEY |
The file resources/models.yml defines three model tiers per vendor (top, default, fastest). Override with $FLOWENGINE_LLM_MODELS_PATH.
models:
vendors:
anthropic:
var: "ANTHROPIC_API_KEY"
top: "claude-opus-4-6"
default: "claude-sonnet-4-6"
fastest: "claude-haiku-4-5-20251001"
openai:
var: "OPENAI_API_KEY"
top: "gpt-5.4"
default: "gpt-5-mini"
fastest: "gpt-5-nano"
gemini:
var: "GEMINI_API_KEY"
top: "gemini-3.1-pro-preview"
default: "gemini-2.5-flash"
fastest: "gemini-2.5-flash-lite"
# Auto-detect from environment (checks Anthropic > OpenAI > Gemini)
client = FlowEngine::LLM.auto_client
# Explicit provider / model override
client = FlowEngine::LLM.auto_client(anthropic_api_key: "sk-ant-...", model: "claude-haiku-4-5-20251001")
# Manual adapter
adapter = FlowEngine::LLM::Adapters::OpenAIAdapter.new(api_key: ENV["OPENAI_API_KEY"])
client = FlowEngine::LLM::Client.new(adapter: adapter, model: "gpt-5-mini")
Sensitive Data Protection
Before any text reaches the LLM, SensitiveDataFilter scans for SSN, ITIN, EIN, and nine-consecutive-digit patterns. If detected, a SensitiveDataError is raised immediately — no LLM call is made.
Option 1: Introduction (One-Shot Pre-Fill)
A flow-level free-form text field parsed by the LLM in a single pass. Good for simple intake where one prompt is enough.
definition = FlowEngine.define do
start :filing_status
introduction label: "Tell us about your tax situation",
placeholder: "e.g. I am married, filing jointly, with 2 dependents...",
maxlength: 2000
step :filing_status do
type :single_select
question "What is your filing status?"
%w[single married_filing_jointly head_of_household]
transition to: :dependents
end
step :dependents do
type :number
question "How many dependents?"
end
end
engine = FlowEngine::Engine.new(definition)
engine.submit_introduction(
"I am married filing jointly with 2 dependents",
llm_client: FlowEngine::LLM.auto_client
)
engine.answers # => { filing_status: "married_filing_jointly", dependents: 2 }
engine.finished? # => true
Option 2: AI Intake Steps (Multi-Round Conversational)
An :ai_intake step type that supports multi-round clarification. Place them anywhere in the flow — including multiple times. The LLM extracts answers for downstream steps and can ask follow-up questions.
definition = FlowEngine.define do
start :personal_intake
# AI intake: collects info for the steps that follow
step :personal_intake do
type :ai_intake
question "Tell us about yourself and your tax situation"
max_clarifications 2 # up to 2 follow-up rounds (0 = one-shot)
transition to: :filing_status
end
step :filing_status do
type :single_select
question "What is your filing status?"
%w[single married_joint married_separate head_of_household]
transition to: :dependents
end
step :dependents do
type :number
question "How many dependents do you claim?"
transition to: :income_types
end
step :income_types do
type :multi_select
question "Select all income types that apply"
%w[W2 1099 Business Investment Rental]
end
end
Running an AI Intake
engine = FlowEngine::Engine.new(definition)
client = FlowEngine::LLM.auto_client
# Round 1: initial submission
result = engine.submit_ai_intake(
"I'm married filing jointly, 2 kids, W2 and business income",
llm_client: client
)
result.done? # => false (LLM wants to ask more)
result.follow_up # => "Which state do you primarily reside in?"
result.round # => 1
result.pending_steps # => [:income_types] (steps still unanswered)
engine.answers # => { filing_status: "married_joint", dependents: 2 }
# Round 2: respond to follow-up
result = engine.submit_clarification(
"California. W2 from my job and a small LLC.",
llm_client: client
)
result.done? # => true (no more follow-ups or max reached)
engine.answers[:income_types] # => ["W2", "Business"]
# Engine auto-advances past all pre-filled steps
engine.finished? # => true
ClarificationResult
Each submit_ai_intake / submit_clarification call returns a ClarificationResult:
| Attribute | Type | Description |
|---|---|---|
answered |
Hash |
Step answers filled this round |
pending_steps |
Array<Symbol> |
Steps still unanswered |
follow_up |
String? |
LLM's follow-up question, or nil if done |
round |
Integer |
Current round number (1-based) |
done? |
Boolean |
True when follow_up is nil |
When max_clarifications is reached, the intake finalizes even if the LLM wanted to ask more. Unanswered steps are presented normally to the user.
Multiple AI Intakes in One Flow
Place :ai_intake steps at multiple points to break up the conversation:
definition = FlowEngine.define do
start :personal_intake
step :personal_intake do
type :ai_intake
question "Tell us about yourself and your tax situation"
max_clarifications 2
transition to: :filing_status
end
step :filing_status do
# ... personal info steps ...
transition to: :financial_intake
end
# Second AI intake mid-flow
step :financial_intake do
type :ai_intake
question "Describe your financial situation: accounts, debts, investments"
max_clarifications 3
transition to: :annual_income
end
step :annual_income do
# ... financial steps ...
end
end
Each :ai_intake step maintains its own conversation history and round counter. State is fully serializable for persistence between requests.
Custom LLM Adapters
class MyAdapter < FlowEngine::LLM::Adapter
def initialize(api_key:)
super()
@api_key = api_key
end
def chat(system_prompt:, user_prompt:, model:)
# Must return response text (expected to be JSON)
end
end
State Persistence
The engine's full state — including AI intake conversation history — can be serialized and restored:
state = engine.to_state
# => { current_step_id: :income_types, answers: { ... }, history: [...],
# introduction_text: "...", clarification_round: 1,
# conversation_history: [{role: :user, text: "..."}, ...],
# active_intake_step_id: :personal_intake }
restored = FlowEngine::Engine.from_state(definition, state)
Round-trips through JSON (string keys) are handled automatically.
Validation
Pluggable validators via the adapter pattern. Ships with NullAdapter (always passes):
class MyValidator < FlowEngine::Validation::Adapter
def validate(node, input)
errors = []
errors << "must be a number" if node.type == :number && !input.is_a?(Numeric)
FlowEngine::Validation::Result.new(valid: errors.empty?, errors: errors)
end
end
engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
Mermaid Diagram Export
exporter = FlowEngine::Graph::MermaidExporter.new(definition)
puts exporter.export
Architecture

The core has zero UI logic, zero DB logic, and zero framework dependencies. Adapters translate input/output, persist state, and render UI.
| Component | Responsibility |
|---|---|
FlowEngine.define |
DSL entry point; returns a frozen Definition |
Definition |
Immutable flow graph (nodes + start step + introduction) |
Node |
Single step: type, question, options/fields, transitions, visibility |
Transition |
Directed edge with optional rule condition |
Rules::* |
AST nodes for conditional logic |
Evaluator |
Evaluates rules against the answer store |
Engine |
Stateful runtime: current step, answers, history, AI intake state |
ClarificationResult |
Immutable result from an AI intake round |
Introduction |
Immutable config for one-shot introduction (label, placeholder, maxlength) |
Validation::Adapter |
Interface for pluggable validation |
LLM::Client |
High-level: builds prompt, calls adapter, parses JSON |
LLM::Adapter |
Abstract LLM API interface (Anthropic, OpenAI, Gemini implementations) |
LLM::SensitiveDataFilter |
Rejects text containing SSN, ITIN, EIN patterns |
Graph::MermaidExporter |
Exports flow as a Mermaid diagram |
Beyond the DSL: LLM-Driven Tools
The declarative DSL is the primary way to define flows, but it is not the only way to use FlowEngine. The gem's LLM integration layer — adapters, client, and sensitive data filter — can power conversational tools that don't use the DSL at all.
bin/tax-estimate (Experimental)
An interactive CLI tool that estimates tax return complexity and preparation cost entirely through LLM-driven conversation. Instead of defining steps in the DSL, the LLM decides what to ask, generates Ruby code for rich terminal prompts (TTY::Prompt), and produces a final YAML estimate when it has enough information.
bin/tax-estimate
How it works:
- A system prompt describes tax complexity factors (filing status, income sources, deductions, special situations)
- The LLM returns JSON containing a natural-language question and a
question_evalfield with Ruby code that usesTTY::Promptfor multi-select, radio buttons, text input, etc. - The generated code is evaluated to collect the user's answer, which is appended to the chat history
- The loop continues until the LLM produces a
final_estimate— a YAML object with complexity score, cost range, and explanation
This approach trades the safety and predictability of a predefined flow graph for maximum flexibility — the LLM adapts its questions based on prior answers without any step definitions.
# The only FlowEngine dependency is the LLM client
client = FlowEngine::LLM.auto_client
response = client.adapter.chat(
system_prompt: tax_system_prompt,
user_prompt: "Chat history: #{chat_history.to_json}",
model: client.model
)
Here is an example of the final YAML estimate for a simple single-filer return:
---
client_profile:
filing_status: Single
dependents: 0
states_required: 1
income_sources:
w2_employers: 1
self_employment: false
rental_income: false
investment_income: false
retirement_distributions: false
foreign_income: false
deductions_and_credits:
itemized_deductions: false
business_expenses: false
education_credits: false
energy_credits: false
child_dependent_care_credits: false
special_situations:
stock_options: false
cryptocurrency: false
foreign_accounts: false
prior_year_carryforwards: false
amended_return: false
irs_notices: false
complexity_assessment:
score: 1
out_of: 10
rationale: >
This is a straightforward return. Single filer, no dependents,
one W-2 from a single employer, one state return, no itemized
deductions, no special income sources, and no special situations.
cost_estimate:
low: $150
high: $300
average: $200
currency: USD
notes: >
Cost reflects a simple federal Form 1040 with a single W-2 and
one state return. Rush fees may apply if filing close to the
deadline.
estimated_time_to_complete: 1-2 hours
See examples/ for more sample outputs including complex multi-income scenarios.
Ecosystem
| Gem | Purpose |
|---|---|
flowengine (this gem) |
Core engine + LLM integration (depends on ruby_llm) |
flowengine-cli |
Terminal wizard via TTY Toolkit + Dry::CLI |
flowengine-rails |
Rails Engine with ActiveRecord persistence and web views |
Development
bundle install
just test # RSpec + RuboCop
just lint # RuboCop only
just doc # Generate YARD docs
License
MIT License. See LICENSE.