Skip to content
All posts
ConfigurationDevelopersReference

Workflow language reference

Every declaration, step, port type and wiring form of the workflow text language, how formulas become versioned links, every check, and the validate, simulate, import and export endpoints.

The BimaStack team · · 12 min read

The complete text form of a BimaStack workflow: every declaration, every step, every port type and wiring form, how formulas become versioned bindings, what is checked, and the endpoints that validate, simulate, import and export a document. New to workflows? Read the guide first.

A document

One file holds at most one workflow, plus any number of records, named formulas and blocks, in any order. The text compiles to exactly the graph the canvas edits; there is no second engine.

Top-level declarations
// a line comment            /* a block comment */

record Traveller { ... }                 // a named row shape
formuladef "travel-age-factor" { ... }   // a reusable formula, by key
blockdef "my-ncd" { ... }                // a reusable sub-workflow, by key
workflow "travel-pricing" { ... }        // the workflow itself (at most one)
  • Names of nodes and ports are identifiers. Anything else (a hyphen, a dot, a reserved word) is written in double quotes: input "vehicle-value-usd" : NUMBER, input "underwriting.loadingPercent" : NUMBER.
  • Keys of workflows, formuladefs and blockdefs are quoted strings. Platform-owned keys start protected:.
  • Strings have no escapes, so a string can’t contain a double quote.
  • Reserved words: workflow, formuladef, blockdef, record, field, input, param, formula, decision, lookup, grouplookup, transform, recordfield, recordfields, aggregate, fetch, block, output, connect, in, out, binding, expression, guard, otherwise, kind, key, from, sign, true, false.

Port types

Type Written Notes
NumberNUMBERThe default: input x is input x : NUMBER
TextSTRING
Yes/noBOOLEAN
DateDATEReaches a formula as an ISO string
Closed listENUM("PRIVATE", "COMMERCIAL")A run with any other value fails at the input. It connects only to an ENUM port with identical values, in order (not to a STRING port), and reaches a formula as a string
RowRECORD:NameA named record; a formula reads row.field
ListLIST<NUMBER | STRING | BOOLEAN | ENUM(...) | RECORD:Name>Lists are worked on by transforms

record, formuladef, blockdef

Declarations
record RateRow {
    field rateValue : NUMBER
    field rowCode : STRING
    field loadable : BOOLEAN          // fields are scalar: NUMBER, STRING, BOOLEAN, ENUM(...)
}

formuladef "ks-age-factor" {
    in driverAge : NUMBER
    expression: """
        let senior = if(driverAge > 70, 1.10, 1.0);
        if(driverAge < 25, 1.15, senior)
        """
}

blockdef "ks-param-block" {
    input amount                      // public input port
    param surcharge : NUMBER          // set by the caller, not a port
    param label : STRING

    aggregate pbSum {
        sign: ADD
    }
    output pbOut {                    // exactly one output: its ports are the block's outputs
    }

    connect amount -> pbSum.currentTotal
    connect surcharge -> pbSum.contribution
    connect pbSum -> pbOut.total
    connect label -> pbOut.label
}

expression: takes the rest of the line, or a """ block over several lines with the common indentation removed, as in a Java text block. Because it takes the whole line, a comment can’t follow an expression on the same line: it would be read as part of the formula. A block’s inputs are its input nodes and its outputs are its one output node’s ports. A param (NUMBER, STRING or BOOLEAN) is read by name inside the block but is not a public port.

The steps

input

input
input sumInsured                          // NUMBER, output port "value"
input usage : ENUM("PRIVATE", "COMMERCIAL", "PSV")
input travellers : LIST<RECORD:Traveller>
input riskScore : NUMBER out score         // rename the output port

formula

formula
formula F_ageLoad {                        // inputs inferred from what is connected
    expression: runningTotal * (factor - 1)
}
formula F_over {
    in oldest : NUMBER                      // or declared
    out flag : BOOLEAN                      // NUMBER (default), BOOLEAN or STRING
    expression: oldest > 70
}
formula F_ageFactor {
    in driverAge : NUMBER
    binding: formula "ks-age-factor"        // run a formula saved under a key
}
formula F_ncd {
    in runningTotal : NUMBER                // no binding: wired through $binding instead
}

The formula must return exactly the declared output type; a mismatch stops the run, never coerces. An output declared BOOLEAN is how a stage raises a rule flag.

decision

decision, true -> and otherwise ->, guard:
decision D_windscreen {
    in hasWindscreen : BOOLEAN
    expression: hasWindscreen
    otherwise -> F_windscreenNil            // runs on false
}
decision D_young {
    in driverAge : NUMBER
    binding: formula "ks-is-young"
    true -> F_youngLevy                     // runs on true
}
formula F_windscreenCharge {
    expression: 30 * usdKes
    guard: D_windscreen.true                // or D_windscreen.false
}

The branch not taken is SKIPPED, and so is any step fed only by a skipped step. Give each branch its own output; the ports of every output that ran merge into one result. A decision’s branch is control, not data: it can’t be wired as a value.

lookup

lookup: static and dynamic keys
lookup L_vat {
    kind: NODE_BINDING
    key: "ks-vat"                           // one fixed key
}
lookup L_ncd {
    kind: NODE_BINDING
    key: "ks:ncd:" from ncdTier             // prefix + the value of the wired port
}
connect ncdTier -> L_ncd
connect L_ncd -> F_ncd.$binding

A lookup finds one configured item in the run’s scope on its date: a formula or block binding (NODE_BINDING), a connection (CONNECTOR_CONNECTION) or an operation (CONNECTOR_OPERATION). Its output is not an ordinary value: it can only feed a $binding, $connection or $operation port. A dynamic key picks different content per run (ks:ncd:GOLD, ks:ncd:SILVER) while the step it feeds stays the same kind.

grouplookup

grouplookup: group mode and array mode
grouplookup levyTables {                   // group mode: every table whose code starts "levy:"
    kind: RATE_TABLE
    keyPrefix: "levy:"
    row: TableName
    project code from key                    // the setting's own key, prefix removed
    project name from "name" default "-"
    out tables
}
grouplookup rows {                           // array mode: one table, its rows; keys by position
    kind: RATE_TABLE
    key: from tableCode
    array: "rows"
    row: TableRateRow
    project k1 from "keys" index 0 default "-"
    project rate from "rate"
    out rows
}

Either way the result is a LIST<RECORD:…>, an ordinary value a transform can work over. Group mode orders rows by key; array mode keeps the array’s order. A default fills a field the item lacks (in array mode, also from the enclosing setting).

transform

op Element ports Formula Output
COUNTexactly one LISTnonecount
SUM, MAX, MINexactly one LIST<NUMBER>nonesum, max, min. MAX and MIN fail on an empty list
FILTERone or more, zipped by indexBOOLEAN, per itemone filtered list per element port, same name
MAPone or more, zipped by indexNUMBER per item, or a blockmapped: LIST<NUMBER>
FIRST_MATCHone or more, zipped by indexBOOLEAN, per itemthe first matching item itself; no match stops the run
transform
transform T_eligible {
    op: FILTER
    elements: age : LIST<NUMBER>             // several lists, zipped item by item
    elements: name : LIST<STRING>
    context: minAge : NUMBER                 // the same value for every item
    expression: age >= minAge and name != "SPAM"
}
transform T_rowContribs {
    op: MAP
    elements: row : LIST<RECORD:RateRow>
    context: sumInsured : NUMBER
    binding: block "ks-row-contribution"     // a block per item: one NUMBER output
}
connect T_eligible.age -> T_eligibleCount.age

recordfield, recordfields

Fields out of a row
recordfield RF_loadCode {
    in row : RECORD:RateRow
    field: rowCode
    out code
}
recordfields PR from primaryRate : RECORD:RateRow   // one step per field: PR_rateValue, PR_rowCode, ...

A formula can read row.field directly; a recordfield is for wiring one field somewhere a formula can’t go, such as a lookup’s dynamic key or a block’s input.

aggregate

aggregate
aggregate A_ncd {
    sign: SUBTRACT                           // or ADD
}
connect A_rowLoad -> A_ncd.currentTotal      // the total so far
connect F_ncd -> A_ncd.contribution          // this step's amount
connect A_ncd -> F_vat.runningTotal          // newTotal, onward

Connections into an aggregate always name the port. One aggregate may feed only one next aggregate: a chain has a single order or it is refused (AMBIGUOUS_CHAIN_ORDER).

fetch

fetch: sugar, or wired by hand
fetch Fe_fxRate {
    in quoteDate : DATE                      // parameters, substituted into the operation
    out usdKes : NUMBER                      // the typed result
    connection: "ks-http-conn"
    operation: "ks-fx-op"
    guard: D_windscreen.true
}
fetch Fe_classFactor {
    in vehicleClass : STRING
    out classFactor : NUMBER
}
connect L_dbConn -> Fe_classFactor.$connection
connect L_dbOp -> Fe_classFactor.$operation

block

block
block B_rate {
    in key1 : STRING
    in x : NUMBER
    out rate : NUMBER                        // must match the block's own ports exactly
    param tableCode = "motor-base-rate"      // literal: NUMBER, STRING or BOOLEAN
    binding: block "protected:table-rate:1d-band"
}
block B_period {                             // bound at run time instead
    in amount : NUMBER
    out adjustment : NUMBER
}
connect L_period -> B_period.$binding

output

output
output O_quote {
    in premium : NUMBER                      // optional: ports are also made by connect
}
connect A_vat -> O_quote.premium
connect B_period.days -> O_quote.coverDays

NUMBER values are rounded to two places, half to even, at an output and nowhere else. Several outputs merge into one result map.

Wiring

Form Means
connect A -> BA’s only output into B, on a port named after A
connect A.port -> B.portExplicit at both ends
connect A -> B.portA’s only output into a named port
connect A.port -> BA named output into a port named after A
connect L -> X.$binding · $connection · $operationA lookup supplies the content a step runs
  • A step with several outputs needs A.port on the left (AMBIGUOUS_SOURCE_PORT otherwise).
  • An input port takes exactly one connection (MULTIPLY_WIRED_INPUT). To choose between values, compute the choice in a formula with if, or dispatch with a dynamic lookup.
  • binding: on a step and a hand-wired $binding are alternatives; using both is BINDING_ALREADY_WIRED_MANUALLY.

Formulas become versions and links

  • expression: alone saves the text under the step’s id as its formula key, so step ids carrying an inline expression must be unique across the whole document, blockdefs included.
  • binding: formula "k" with expression: saves the text under k.
  • binding: formula "k" alone refers to a formula already saved; a key nobody has saved is the warning UNKNOWN_FORMULA_KEY.
  • formuladef "k" saves under k, for any number of steps to bind.

Importing saves a new formula version only when the text changed, and drafts one link (a node binding) per authored key, pinning that version in the import’s scope. Re-importing an unchanged document creates nothing new.

Checks

Every diagnostic is {stage, rule, message, line, column, offendingToken, suggestion, severity}, in the formula language’s shape, so one editor component shows both. Only ERRORs make a document invalid.

Stage Examples
LEX, PARSEAn unknown character; “Expected ’out’, ’in’, ’binding’, or ’expression’ inside formula body.”
DSL_SEMANTICUNKNOWN_NODE_REFERENCE, UNKNOWN_RECORD_TYPE, DUPLICATE_NODE_DECLARATION, MULTIPLE_WORKFLOW_DECLARATIONS, INVALID_FORMULA_OUTPUT_TYPE, GUARD_SOURCE_NOT_DECISION, BLOCK_UNKNOWN_PARAM (with “did you mean”), BLOCK_MISSING_PARAM, BLOCK_PARAM_TYPE_MISMATCH, BLOCKDEF_OUTPUT_COUNT, TRANSFORM_ELEMENT_NOT_LIST
GRAPH_VALIDATIONPORT_TYPE_MISMATCH, UNWIRED_REQUIRED_INPUT, MULTIPLY_WIRED_INPUT, CYCLE_DETECTED, UNREACHABLE_NODE, AMBIGUOUS_CHAIN_ORDER, INVALID_TRANSFORM_ARITY, BLOCK_PORT_MISMATCH, BLOCK_CYCLE_DETECTED, BLOCK_MAX_DEPTH_EXCEEDED
FORMULAThe formula language’s own findings, moved to the line and column in your file
WarningsTOO_MANY_TOP_LEVEL_NODES (over ~30), UNKNOWN_FORMULA_KEY, BLOCK_BINDING_UNRESOLVED (a block not published in this scope yet), BLOCK_BINDING_UNBOUND (a block needs formula links your scope doesn’t have)

Blocks nest at most six levels and never recursively. A call’s params are checked against a blockdef in the same document, or against the published block when the request carries a scope.

Endpoints

Call Does
POST /api/workflow/dsl/validate{source, scope?, asOfDate?} → {valid, diagnostics}. Writes nothing; safe to call as the author types
POST /api/workflow/dsl/compileAs validate, plus the compiled graph and blocks
POST /api/workflow/dsl/simulate{source, scope?, asOfDate?, entry?, inputs} → {valid, diagnostics, outputs, trace}. Runs unsaved text, its own formulas and blockdefs standing in for saved ones; entry {"kind": "block", "key": …} runs one blockdef alone
POST /api/workflow/dsl/import{source, scope, effectiveFrom, effectiveTo?, expectedWorkflowVersion?, expectedBlockVersions?, reason} → {definitions, formulas, bindings, warnings}. All or nothing; everything saved is a DRAFT
GET /api/workflow/dsl/export/{workflow|block}/{id}The text: STORED (as authored, comments kept) or CANONICAL (printed from the graph)
GET /api/workflow/dsl/completions?…scope…&asOfDate=What an editor can’t read from the text: published blocks with their ports, params and required links; workflow, formula and lookup keys; the built-in functions
GET /api/workflow/dsl/formulas/{bindingKey}?…scope…The formula text a link runs in a scope, and whether newer text is saved but not yet linked (stale)
Run unsaved text
curl -s -X POST https://<your-address>/api/workflow/dsl/simulate \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile src motor-premium.wf '{
    source: $src,
    scope: {organizationId: "<organisation>"},
    asOfDate: "2026-10-09",
    inputs: {
      vehicleValue: {type: "NUMBER", value: "2000000"},
      driverAge:    {type: "NUMBER", value: "23"}
    }
  }')"
  • Values are {"type", "value"}; a list is {"type": "LIST", "elementType", "elements": [...]}; a record carries its schema: {"type": "RECORD", "recordType": {name, fields}, "fields": {...}}. A malformed value is a 400 naming the input.
  • Outputs and trace carry NUMBERs at full precision; rounding happens inside the engine at output steps.
  • Import over an existing key needs its current version (expectedWorkflowVersion, expectedBlockVersions), else 409; a new version needs a later effectiveFrom.
  • What doesn’t round-trip: comments (except in STORED export), canvas positions and labels, the ids of generated binding steps, and the order of a step’s input ports.

Try the language in Sandbox

A complete BimaStack for developers, wiped every night.

For developers