Formula Authoring Guide
Learn how to write formulas in STELLWERK's visual flow builder. This guide covers everything from basic assignments to advanced collection operations.
Overview
Formulas are the heart of STELLWERK's calculation engine. They let you define business logic, perform calculations, and transform data without writing code. Formulas use a safe, Excel-like syntax powered by the Dentaku expression engine.
What You Can Do
-
Perform arithmetic:
price * quantity -
Apply conditions:
IF(total > 100, 0.1, 0) -
Process arrays:
SUM(items[*].price) - Chain calculations across multiple nodes
Key Concepts
- Variables store values you can reference
- Assignments create new variables
- Exports pass values to downstream nodes
- Functions perform operations on data
Where Formulas Are Used
Your First Formula
TutorialLet's build a simple pricing formula step by step. We'll calculate a total price with a discount.
Start with Inputs
Assume your StartNode defines two inputs: quantity and unit_price.
# These come from your StartNode inputs quantity = 5 unit_price = 29.99
Calculate the Subtotal
In a CalculateNode, multiply quantity by price:
subtotal = quantity * unit_price
Result: subtotal = 149.95
Apply a Conditional Discount
Use IF() to apply 10% discount for orders over $100:
discount_rate = IF(subtotal > 100, 0.10, 0) discount = subtotal * discount_rate total = subtotal - discount
Result: discount = 14.995, total = 134.955
All Variables Flow Downstream
All assigned variables are automatically available to the next node. No export needed!
subtotal = quantity * unit_price discount_rate = IF(subtotal > 100, 0.10, 0) discount = subtotal * discount_rate total = subtotal - discount # All four variables (subtotal, discount_rate, discount, total) # are automatically available in the next node
Complete Formula
# Calculate order total with volume discount subtotal = quantity * unit_price discount_rate = IF(subtotal > 100, 0.10, 0) discount = subtotal * discount_rate total = subtotal - discount
Input: quantity=5, unit_price=29.99
Output: subtotal=149.95, discount_rate=0.10, discount=14.995, total=134.955
Assignments
CoreAssignments create new variables by computing expressions. Each assignment stores a value that can be used later in the same formula or exported to downstream nodes.
Syntax
variable_name = expression
Rules:
- Variable names must start with a letter or underscore
-
Can contain letters, numbers, and underscores:
my_var_2 - One assignment per line
-
Use
snake_casefor readability
Examples
Simple arithmetic:
total = price * quantity tax = total * 0.19 grand_total = total + tax
Using functions:
max_price = MAX(price_a, price_b, price_c) rounded = ROUND(total, 2) absolute = ABS(difference)
Conditional assignment:
discount = IF(is_member, 0.15, 0.05) status = IF(quantity > 0, "in_stock", "out_of_stock")
String values:
message = "Order confirmed" error_code = 'ERR_NOT_FOUND'
Comments
Use # to add comments. Comments are ignored during execution.
# Calculate the base price base = quantity * unit_price # Apply regional tax rate (19% for DE) tax = base * 0.19 # Final price including tax total = base + tax
Available Operators
Arithmetic:
| + | Addition |
| - | Subtraction |
| * | Multiplication |
| / | Division |
| % | Modulo (remainder) |
| ^ | Exponentiation |
Comparison:
| == | Equal |
| != | Not equal |
| > | Greater than |
| < | Less than |
| >= | Greater or equal |
| <= | Less or equal |
Variable Availability
By default, all variables you assign in a CalculateNode are automatically available to downstream nodes in the same branch. You don't need to explicitly export them.
export statement needed!
How Variables Flow
Default: All variables available
Every variable you assign is automatically passed downstream
subtotal = quantity * price
tax = subtotal * 0.19
total = subtotal + tax
# All three (subtotal, tax, total) are available downstream
Optional: Limit with export
Use export only if you want to restrict which variables are passed
_temp = quantity * price _adjusted = _temp * 0.95 total = _adjusted + fees export total # Only 'total' is available downstream
Alternative: return for single value
Shorthand when you only want one variable downstream
base = hours * rate
overtime = MAX(hours - 40, 0) * rate * 0.5
gross_pay = base + overtime
return gross_pay
When to Use export
Use export or return when you want to:
- Hide internal variables: Prevent temporary calculations from cluttering the context
- Be explicit: Document exactly which values are important outputs
- Avoid naming conflicts: Prevent intermediate variables from overwriting upstream values
Examples
# No export needed - all three flow downstream subtotal = quantity * unit_price tax = subtotal * 0.19 total = subtotal + tax # Next node can use: subtotal, tax, total
# Only pass the final total, hide internals _base = quantity * unit_price _adjusted = _base * discount_factor total = _adjusted + shipping export total # Next node can only use: total
# return is shorthand for export when you have one output base = hours * hourly_rate overtime = MAX(hours - 40, 0) * hourly_rate * 0.5 gross_pay = base + overtime return gross_pay
Export Syntax
When using export, separate variable names with commas or spaces:
export subtotal, tax, total export subtotal tax total # also valid
Conditions
Branching
Conditions are boolean expressions that evaluate to true or false. They're used in ConditionNodes for branching and in SwitchNodes for case matching.
Condition Node
Splits flow into "true" and "false" branches based on a single condition.
total > 100 AND is_member
Switch Node
Routes to different branches based on multiple case conditions.
region == "EU"
region == "US"
# default case
Logical Operators
| Operator | Meaning | Example |
|---|---|---|
| AND | Both must be true | age >= 18 AND has_license |
| OR | At least one must be true | is_admin OR is_owner |
| NOT | Inverts the result | NOT is_blocked |
Condition Examples
Simple comparisons:
# Numeric comparison quantity > 10 # String comparison status == "active" # Not equal country != "US"
Combined conditions:
# Both must be true total > 100 AND customer_type == "premium" # Either can be true is_sale OR has_coupon # Complex logic with parentheses (age >= 18 AND age <= 65) OR has_guardian_consent
Real-world examples:
# Free shipping eligibility (subtotal >= 50 AND region == "domestic") OR is_prime_member # Discount qualification (order_count > 5 OR total_spent > 500) AND NOT is_first_order # Business hours check hour >= 9 AND hour < 17 AND day != "Sunday"
IF Function in Formulas
Use IF(condition, value_if_true, value_if_false) for conditional values within CalculateNodes:
# Simple conditional discount = IF(is_member, 0.15, 0.05) # Tiered pricing rate = IF(quantity > 100, 0.80, IF(quantity > 50, 0.90, 1.0)) # Status assignment status = IF(score >= 70, "pass", "fail") # Nested conditions shipping = IF(is_local, 0, IF(weight > 10, 15.99, 5.99))
Important: No assignments in conditions
Conditions must be pure expressions. You cannot use = for assignment in a ConditionNode or SwitchNode case.
# Wrong - this is an assignment, not a comparison x = 5 # Correct - this is a comparison x == 5
Variables & Scope
Variables are the data that flows through your nodes. Understanding where variables come from and where they can be used is key to building reliable flows.
Where Variables Come From
Runtime Parameters (API Inputs)
Values passed when executing the flow via API
# From API call: { "quantity": 5, "price": 29.99 }
total = quantity * price
StartNode Defaults
Default values defined in your StartNode input schema
# StartNode defines: tax_rate with default 0.19 tax = subtotal * tax_rate # uses 0.19 if not provided
Flow Metadata
Key-value pairs defined on the Flow itself
# Flow metadata: { "version": "2.0", "region": "EU" }
shipping = IF(region == "EU", 5.99, 9.99)
From Upstream Nodes
All variables from nodes earlier in the flow
# Previous node calculated 'subtotal' and 'discount'
final = subtotal - discount
Variable Priority (highest to lowest)
- Runtime parameters (API inputs) - always win
- Exported variables from upstream nodes
- Flow metadata
- StartNode defaults
Understanding Scope
All assigned variables flow downstream:
# Node A temp = price * 1.1 result = temp + fees # Node B (downstream) - can use BOTH variables x = result # ✓ works y = temp # ✓ works - all variables available!
Variables flow forward, not backward:
# Flow: StartNode → NodeA → NodeB → EndNode # NodeA can use: # - Runtime inputs # - StartNode defaults # - Flow metadata # NodeB can use: # - Everything NodeA can use # - Plus: all variables from NodeA
Naming Conventions
Recommended:
-
snake_case -
unit_price -
_internal_temp(prefix with _ for internal use)
Avoid:
-
camelCase -
123var(can't start with number) -
my-var(no hyphens)
Note: Variables are case-sensitive. total and Total are different variables.
Accessing Nested Data
Use dot notation to access nested object properties:
# Given input: { "order": { "total": 150, "items": [...] } } amount = order.total tax = order.total * 0.19 # Works with deeper nesting too city = customer.address.city
Aggregation Functions
NumbersAggregation functions compute a single value from multiple inputs. They work with both arrays and multiple arguments.
SUM
Returns: NumberAdds up all numeric values. Non-numeric values are ignored.
# With multiple arguments total = SUM(10, 20, 30) # → 60 # With an array total = SUM(prices) # → sum of all prices # With projection (array of objects) total = SUM(items[*].price) # → sum of all item prices # Mixed types (strings converted if numeric-looking) total = SUM(["5", 2, "3.5"]) # → 10.5 # Empty array total = SUM([]) # → 0
COUNT
Returns: IntegerCounts non-nil values. Zeros and empty strings are counted; nil/null values are not.
# Count array elements num_items = COUNT(items) # → number of items # Count with projection num_prices = COUNT(items[*].price) # → count of price values # Nil values excluded count = COUNT([1, nil, 3, 0]) # → 3 (nil excluded, 0 counts) # Multiple arguments count = COUNT(a, b, c) # → counts non-nil args
MIN / MAX
Returns: Number or nilFind the minimum or maximum numeric value. Returns nil if no numeric values found.
# Find extremes lowest = MIN(prices) # → smallest price highest = MAX(prices) # → largest price # With projection min_price = MIN(items[*].price) # → cheapest item max_price = MAX(items[*].price) # → most expensive item # Multiple arguments biggest = MAX(a, b, c) # → largest of a, b, c # Numeric string conversion smallest = MIN(["5", 2, "3.5"]) # → 2 # Empty array result = MIN([]) # → nil
AVERAGE / AVG
Returns: Number or nilCalculates the arithmetic mean of numeric values. Returns nil if no numeric values found.
# Average of array avg_price = AVERAGE(prices) # → mean of all prices avg_price = AVG(prices) # → same (alias) # With projection avg_score = AVERAGE(reviews[*].rating) # → average rating # Multiple arguments mean = AVERAGE(10, 20, 30) # → 20 # Empty array result = AVERAGE([]) # → nil
Understanding Projection Syntax
The [*] syntax extracts a field from each element in an array:
# Given: items = [{ price: 10 }, { price: 20 }, { price: 30 }] items[*].price # → [10, 20, 30] SUM(items[*].price) # → 60 # Nested objects work too: orders[*].customer.name # → extracts name from each order's customer
Collection Functions
ArraysCollection functions help you work with arrays - selecting elements, removing duplicates, and accessing specific items.
FIRST / LAST
Returns: Element or nilGet the first or last element of an array. Returns nil for empty arrays.
# Get first/last element first_item = FIRST(items) # → first element last_item = LAST(items) # → last element # With projection first_price = FIRST(items[*].price) # → price of first item last_name = LAST(users[*].name) # → name of last user # Single element (scalar) auto-wrapped result = FIRST(42) # → 42 # Empty array result = FIRST([]) # → nil
TAKE
Returns: ArrayGet the first N elements from an array. Useful for limiting results.
# Get first N elements top_three = TAKE(items, 3) # → first 3 items # With projection top_prices = TAKE(items[*].price, 5) # → first 5 prices # Negative or zero returns empty result = TAKE(items, 0) # → [] result = TAKE(items, -2) # → [] # More than available returns all result = TAKE([1, 2, 3], 10) # → [1, 2, 3]
DISTINCT
Returns: ArrayRemove duplicate values from an array. Preserves order (keeps first occurrence).
# Remove duplicates unique = DISTINCT([1, 2, 2, 3, 1]) # → [1, 2, 3] # With strings unique_names = DISTINCT(["a", "b", "a"]) # → ["a", "b"] # With projection - unique categories categories = DISTINCT(products[*].category) # Count unique values unique_count = COUNT(DISTINCT(items[*].type))
PROJECT
Returns: Array
Extract a field from each element in an array. This is what [*] syntax uses internally.
# These are equivalent: prices = items[*].price prices = PROJECT(items, "price") # Missing fields become nil # [{ price: 10 }, { name: "x" }] → [10, nil]
Practical Examples
# Get unique countries from orders countries = DISTINCT(orders[*].shipping_country) # Find price range price_range = MAX(items[*].price) - MIN(items[*].price) # Get top 3 most recent items (assuming sorted) recent = TAKE(items, 3) # Check if first item is expensive first_is_premium = FIRST(items[*].price) > 100
Transformation Functions
AdvancedTransformation functions let you modify each element in an array, filter elements based on conditions, or accumulate values with custom logic.
MAP
Returns: Array
Transform each element using an expression. Use item to reference the current element.
# Double each value doubled = MAP([1, 2, 3], "item * 2") # → [2, 4, 6] # Apply discount to prices discounted = MAP(prices, "item * 0.9") # → 10% off each # Access object fields via 'item' or directly totals = MAP(items, "item.price * item.quantity") totals = MAP(items, "price * quantity") # same result # Calculate line totals with tax with_tax = MAP(items, "price * quantity * 1.19")
Tip: When mapping over objects, each key is available as a variable. So for {price: 10, qty: 2}, you can use price * qty directly.
FILTER
Returns: Array
Keep only elements where the predicate is true. Use item to reference each element.
# Keep values over 10 large = FILTER([5, 15, 8, 20], "item > 10") # → [15, 20] # Filter by object property expensive = FILTER(items, "price > 50") # Filter by category electronics = FILTER(products, "category == 'electronics'") # Multiple conditions premium = FILTER(items, "price > 100 AND in_stock") # Combine with aggregation expensive_total = SUM(FILTER(items, "price > 50")[*].price)
REDUCE
Returns: Any
Accumulate values with custom logic. Use acc (or accumulator) for the running total and item for each element.
# Sum (same as SUM function) total = REDUCE([1, 2, 3], 0, "acc + item") # → 6 # Product of all values product = REDUCE([2, 3, 4], 1, "acc * item") # → 24 # Find running maximum max_val = REDUCE(values, 0, "IF(item > acc, item, acc)") # Sum with weighted values weighted = REDUCE(items, 0, "acc + (price * weight)")
Note: REDUCE is powerful but complex. For simple sums, counts, min/max, prefer the dedicated functions which are more readable.
TRACE
Returns: Same valuePass-through function for debugging. Returns the value unchanged. (Reserved for future instrumentation.)
# Debug intermediate value result = TRACE(complex_calculation, "step1") # Currently just returns the first argument unchanged
Chaining Transformations
Combine functions for powerful data processing:
# Get total of expensive items only expensive_items = FILTER(items, "price > 50") expensive_total = SUM(expensive_items[*].price) # Apply 20% discount to all items, then sum discounted_prices = MAP(items, "price * 0.8") discounted_total = SUM(discounted_prices) # Count unique categories of in-stock items in_stock = FILTER(products, "stock > 0") unique_categories = COUNT(DISTINCT(in_stock[*].category))
Conditional Functions
LogicConditional functions combine aggregation with filtering, letting you sum or count only elements that match a condition.
SUMIF
Returns: NumberSum values where a condition is true. Optionally project a field to sum.
# Sum prices of expensive items expensive_total = SUMIF(items, "price > 50", "price") # Sum values directly (no projection needed for simple arrays) positive_sum = SUMIF([5, -3, 10, -2], "item > 0") # → 15 # Sum by category electronics_total = SUMIF(products, "category == 'electronics'", "price") # Sum with compound condition premium_sales = SUMIF(orders, "total > 100 AND status == 'completed'", "total") # Real-world: Sum line totals for taxable items taxable_amount = SUMIF(line_items, "is_taxable", "price * quantity")
COUNTIF
Returns: IntegerCount elements where a condition is true.
# Count expensive items expensive_count = COUNTIF(items, "price > 50") # Count positive values positive_count = COUNTIF([5, -3, 10, -2], "item > 0") # → 2 # Count by status active_users = COUNTIF(users, "status == 'active'") pending_orders = COUNTIF(orders, "status == 'pending'") # Count with compound condition vip_count = COUNTIF(customers, "total_spent > 1000 AND is_subscribed") # Check if any match (count > 0) has_errors = COUNTIF(items, "has_error") > 0
SUMIF/COUNTIF vs FILTER
These are equivalent approaches - use whichever is more readable:
# Using SUMIF (more concise) total = SUMIF(items, "price > 50", "price") # Using FILTER + SUM (more explicit) expensive = FILTER(items, "price > 50") total = SUM(expensive[*].price) # Using COUNTIF count = COUNTIF(items, "in_stock") # Using FILTER + COUNT count = COUNT(FILTER(items, "in_stock"))
Real-World Calculations
# E-commerce order summary subtotal = SUM(items[*].price) taxable_amount = SUMIF(items, "is_taxable", "price") tax = taxable_amount * 0.19 discounted_items = COUNTIF(items, "has_discount") # Inventory analysis low_stock_count = COUNTIF(products, "stock < 10") out_of_stock_value = SUMIF(products, "stock == 0", "price * reorder_qty") # Sales report completed_revenue = SUMIF(orders, "status == 'completed'", "total") pending_count = COUNTIF(orders, "status == 'pending'") refunded_amount = SUMIF(orders, "status == 'refunded'", "total")
Error Messages
TroubleshootingWhen something goes wrong, the formula validator provides helpful error messages. Here's how to understand and fix common issues.
| Error | Cause | Fix |
|---|---|---|
| Unknown: var1, var2 | Referenced variables don't exist in scope | Check spelling, ensure variable is defined in an upstream node, or define it in StartNode |
| Assignment not allowed in condition | Used = for assignment in a ConditionNode |
Use == for comparison, or move assignment to a CalculateNode |
| Right-hand side required | Assignment has no value after = |
Add an expression: x = 10 |
| Unbalanced parentheses | Opening and closing parentheses don't match | Count ( and ) - they should be equal |
| Required input missing: X | StartNode requires input X but it wasn't provided | Provide the input in API call, or add a default value in StartNode |
| MapNode source 'X' is not an array | MapNode expects an array but got a different type | Ensure the source variable is an array in your input or upstream calculation |
Common Mistakes
Wrong: Using = instead of == in conditions
# In a ConditionNode - WRONG status = "active" # Correct status == "active"
Wrong: Using undefined variable
# 'discount_rate' was never defined - WRONG final = subtotal * (1 - discount_rate) # Correct: define it first or pass as input discount_rate = 0.1 final = subtotal * (1 - discount_rate)
Wrong: Using export with undefined variable
# Trying to export a variable that doesn't exist - WRONG total = price * quantity export total, discount # ERROR: 'discount' was never defined # Fix: only export variables you've defined discount = IF(quantity > 10, 0.1, 0) total = price * quantity * (1 - discount) export total, discount # ✓ both variables exist
Wrong: Case sensitivity mismatch
# Input is 'userId' but you reference 'userid' - WRONG result = userid * 2 # Unknown: userid # Correct: match exact case result = userId * 2
Debugging Tips
- Check the context in the API response to see all available variables
- Use applied_nodes in the response to see which nodes executed
- Start simple: test formulas with hardcoded values before using variables
-
Use comments to document expected values:
# expects price > 0 - Create test cases in the flow to validate expected inputs/outputs
Best Practices
GuidelinesFollow these guidelines to write maintainable, readable, and reliable formulas.
1 Use Clear, Descriptive Names
Good
order_subtotal = quantity * unit_price shipping_cost = IF(order_subtotal > 50, 0, 5.99) tax_amount = order_subtotal * tax_rate
Avoid
x = q * p s = IF(x > 50, 0, 5.99) t = x * r
2 Keep Formulas Focused
Each CalculateNode should do one thing well. Split complex logic across multiple nodes.
Better: Split into nodes
# Node 1: Calculate subtotal subtotal = quantity * unit_price # subtotal flows automatically to next node # Node 2: Apply discounts discount = IF(subtotal > 100, 0.1, 0) discounted = subtotal * (1 - discount) # discount, discounted flow automatically # Node 3: Add tax and shipping tax = discounted * 0.19 shipping = IF(discounted > 50, 0, 5.99) total = discounted + tax + shipping
Harder to maintain: All in one
subtotal = quantity * unit_price discount = IF(subtotal > 100, 0.1, 0) discounted = subtotal * (1 - discount) tax = discounted * 0.19 shipping = IF(discounted > 50, 0, 5.99) total = discounted + tax + shipping # All 6 variables flow downstream automatically
3 Use Export to Limit Variables
By default, all assigned variables flow downstream. Use export only when you want to limit which variables are passed.
# Without export: gross, tax, and net ALL flow downstream gross = hours * rate tax = gross * 0.25 net = gross - tax # With export: ONLY net flows downstream (hides internals) export net
4 Document Business Logic
Use comments to explain the "why", especially for business rules that aren't obvious.
# Loyalty discount: 15% for customers with 5+ orders # As per marketing policy Q4-2025 loyalty_discount = IF(order_count >= 5, 0.15, 0) # Free shipping threshold is $50 domestic, $100 international free_shipping_threshold = IF(is_domestic, 50, 100) shipping = IF(subtotal >= free_shipping_threshold, 0, base_shipping)
5 Prefix Internal Variables
Use underscore prefix as a convention to indicate internal/temporary calculations. Combine with export to hide them from downstream nodes.
# Internal calculations (prefixed with _ by convention) _base_score = points / max_points * 100 _with_bonus = _base_score + bonus_points _capped = MIN(_with_bonus, 100) # Final result (no prefix - the main output) final_score = ROUND(_capped, 0) # Use export to only pass final_score downstream export final_score
6 Create Test Cases
Use the built-in test case feature to validate your flow with known inputs and expected outputs.
- Test edge cases: zero values, empty arrays, boundary conditions
- Test each conditional branch to ensure all paths work
- Document expected behavior with descriptive test case names
Frequently Asked Questions
Q:
Do I need to use export?
A: No! By default, all assigned variables automatically flow to downstream nodes. Only use export or return if you want to limit which variables are passed.
Q: Are variable names case-sensitive?
A: Yes. total, Total, and TOTAL are three different variables. Stick to snake_case for consistency.
Q: Can I export a variable I didn't assign?
A: No. Exporting an unknown variable will cause an error. You can only export variables that exist in scope (defined in the current node or available from upstream).
Q:
Are there built-in default variables like price or metadata?
A: No. There are no magic default variables. All variables come from:
- Runtime parameters (API inputs)
- StartNode defaults
- Flow metadata
- Exported from upstream nodes
Q: What happens if I divide by zero?
A: Division by zero will cause an execution error. Protect against it with a condition:
result = IF(divisor != 0, value / divisor, 0)
Q: How do I handle null/nil values?
A: Use conditional logic or provide defaults:
# Using IF to provide default safe_value = IF(value, value, 0) # Or use StartNode defaults for inputs
Q: Can I call one flow from another?
A: Yes, using MapNodes. A MapNode can execute a sub-flow for each element in an array. This is useful for processing collections with complex logic.
Q: Why does my function show "unknown variable" warning?
A: Check these common issues:
-
Spelling:
SUMnotSumorsum -
Parentheses:
SUM(items)notSUM items - Recognized functions: see the Functions sections for the complete list
Q: How do I pass arrays from the API?
A: Pass arrays directly in your JSON input:
{
"inputs": {
"items": [
{ "name": "Widget", "price": 10 },
{ "name": "Gadget", "price": 25 }
]
}
}
Then use projection: SUM(items[*].price)
Q: Is the formula language Turing-complete?
A: No, intentionally. Formulas are designed for safe, predictable calculations. There are no loops or recursion to prevent infinite execution. Use multiple nodes for complex logic.