Cloud Semantics
A local run (flowforger run, the debugger, the web app's Run button) is only useful if it behaves like the deployed flow. The FlowForger engine follows Power Automate's rules, not JavaScript's: values become text the way the cloud writes them, failures get the cloud's codes and messages, and a skipped or failed action affects the rest of the run the way it does in Power Automate. These rules were measured by running the same test flows in the cloud and locally and comparing every action.
This page lists the rules that most often surprise people, and the few places where a local run still differs.
Expressions
Values as text
When a value becomes text (string(), concat(), join(), or @{...} interpolation):
| Value | Text |
|---|---|
true / false | True / False |
null | '' (empty) |
7.0 | 7 (numbers are written as .NET writes them) |
| an object or array | its JSON, e.g. [1,"a"] |
@concat(true, '|', null, '|', 7.0) → True||7
Total: @{add(1, 2)} and @{true} → Total: 3 and True
@{...} is evaluated anywhere in a string, not only at the start. @@ escapes a literal @: '@@literal' gives @literal.
Comparisons
equals()is type-strict and deep:equals(1, '1')isfalse, and arrays and objects compare element by element. String comparison inequals()is case-sensitive. With more than two arguments, it istruewhen all of them equal the first.greater(),less(),greaterOrEquals(),lessOrEquals()compare numbers numerically and strings ignoring case. Comparing a number with a string fails the action.and(),or()andnot()insist on booleans.if(),and(),or()andcoalesce()evaluate only the arguments they need, soif(true, 'ok', int('abc'))is'ok'.- There are no
georlefunctions; usegreaterOrEqualsandlessOrEquals.
Numbers
div() of two integers is integer division: div(7, 2) is 3. A float literal or float() makes it a float division: div(7.0, 2) is 3.5. The DSL's / compiles to div(), so ctx.variables('count') / 2 on an integer variable rounds down.
There is no abs(), ceil(), floor() or round() in Power Automate, and a local run fails on them just like the cloud. formatNumber(x, 'F0') rounds to a whole number as text.
Strings and case
indexOf(),lastIndexOf(),nthIndexOf(),startsWith()andendsWith()ignore case:startsWith('Hello', 'he')istrue.contains(),replace()andsplit()are case-sensitive.- Property names match case-insensitively:
?['name']finds a property calledNamewhen there is no exact match.
Missing properties
?['x'] on a missing property, on null, or past the end of an array gives null. Without the ?, the same read fails the action. In the DSL, ?.['x'] compiles to the null-safe ?['x'], while .x and ['x'] compile to the strict form:
await ctx.compose('Safe', ctx.triggerBody()?.['customer']?.['email']); // null when missing
await ctx.compose('Strict', ctx.triggerBody().customer.email); // fails when missing
Functions and arguments
- An unknown function fails the action. It does not pass through as text.
- A call with a number of arguments the function does not take fails when it runs, with that function's own message (for example
add(1)). The cloud saves such a flow; the editor warns withDSL050. createArray()needs at least one argument. In the DSL, an empty array literal in an expression (x ?? []) compiles tojson('[]'), which works everywhere.item()outside a loop isnull.action()fails in a local run outside the debugger console; the cloud allows it only in tracked properties.
Dates
Timestamps keep seven fraction digits and their kind: utcNow() gives 2026-11-07T20:45:48.0580000Z, and addDays('2026-01-01T10:00:00', 1) gives 2026-01-02T10:00:00.0000000 (no zone in, no zone out). Format strings and cultures are .NET's (formatDateTime(x, 'dd.MM.yyyy', 'de-DE')).
How actions end
actions() and result()
ctx.actions('X') and each item of ctx.result('Scope') have the cloud's record shape:
{
"name": "Get_order",
"inputs": { "...": "..." },
"outputs": { "statusCode": 404, "body": { "...": "..." } },
"startTime": "...",
"endTime": "...",
"trackingId": "...",
"clientTrackingId": "...",
"code": "NotFound",
"status": "Failed"
}
statusisSucceeded,Failed,Skipped,TimedOutorCancelled.codeis the HTTP status name for HTTP, connector and child-flow actions (OK,NotFound,BadRequest; statuses without a .NET name, such as 429, appear as the number). A failed HTTP or connector call has noerror; readoutputs.statusCodeandbodyinstead.- An expression that fails gives code
BadRequestand anerrorwith codeInvalidTemplate. - A block that failed because something inside it failed has code
ActionFailed. - A skipped action has code
ActionSkippedand anerrorwhose code says why:ActionConditionFailed(its runAfter was not met),ActionBranchingConditionNotSatisfied(it is in anif/switchbranch that was not taken), orActionDependencyFailed(its block never ran its body). - Blocks (scope, if, switch, loops) have no inputs or outputs in these records.
result() of a foreach lists each child action once, with every iteration's record in its outputs. result() of an Until lists the last iteration, with a repetitionCount.
Loops
- A foreach runs every item even after one fails. The loop then ends Failed.
- An Until (
whileanddo...whilein the DSL) runs its body before checking the condition, so the body always runs at least once, even for awhilewhose condition is false at the start. - An Until continues past a failed iteration and takes the status of its last iteration. Reaching the count limit ends it Succeeded.
- A foreach whose items expression fails, or an
ifwhose condition fails, ends withBadRequest/InvalidTemplate, and its body is recorded as Skipped (ActionDependencyFailed).
Branches and switches
- The branch of an
iforswitchthat was not taken is recorded as Skipped, and appears in the trace. - A
switchfails when its value is not a string or an integer. A case value of a different type than the switch value ('2'against2) fails the switch's actions, because the cloud compares them type-strictly.
Terminate
ctx.terminate() itself succeeds. Every block around it ends Cancelled, everything after it is Skipped, and the run ends with the given status and run error.
The run's status
The run fails when a failure is not handled: an action that failed, with no later action whose runAfter accepts the failure. A catch scope with @runAfter Try: Failed handles the failure, so the run can end Succeeded.
workflow()
workflow().tags.flowDisplayName is the flow name. workflow().tags.environmentName is set when you pass --environment-id to flowforger run.
Trigger data
What you pass in (--in payload.json, a child-flow call, the debugger's trigger payload) is the trigger body. The trigger outputs are:
{ "headers": { "Content-Type": "application/json" }, "body": { "...": "your payload" } }
so ctx.triggerBody() is your payload and ctx.triggerOutputs() has both headers and body. A payload that already has this shape (a body plus only headers, queries, statusCode or relativePathParameters) is used as is, which lets you pass request headers:
{ "headers": { "x-ms-user-email": "megan@contoso.com" }, "body": { "orderId": 42 } }
ctx.trigger() is the trigger's run record (name, inputs, outputs, startTime, status, ...). It has no body of its own: read trigger()['outputs']['body'], or simply triggerBody().
For a raw request body, such as a form post or multipart data, pass --in-content-type. The body is then stored the way the cloud's Request trigger stores it, so triggerFormDataValue() and triggerMultipartBody() work:
flowforger run flow.ff.ts --in form.txt --in-content-type application/x-www-form-urlencoded
HTTP actions
The HTTP action follows the cloud's request and response rules: queries replace the URL's own query string, a status of 400 or more fails the action with the response as its outputs, a 202 with a Location header is polled until it completes, and an action without a retryPolicy uses the cloud's default policy (up to 4 exponential retries on 408, 429 and 5xx). See HTTP Connector for the details.
Child flows
ctx.callWorkflow() gets what the child flow's first Response action answered, as { statusCode, headers, body }.
- A response below 400 succeeds the call; 400 or more fails it, with the response as outputs.
- A child that ends without responding fails the call with 502 BadGateway and a
NoResponseerror, even when the child itself succeeded (also when it ended with Terminate Succeeded). - A child that responds and then fails still succeeds the call.
- The child's trigger schema and the response schema do not convert or validate anything.
In the cloud, the default retry policy re-runs a child that did not respond, for several minutes. Locally the call is made once.
Parse JSON
ctx.parseJson() reads text the way the cloud does, which is more lenient than JSON.parse: single quotes, unquoted names, comments, trailing commas and duplicate keys (the last wins) are accepted, and text after the first value is ignored.
- Text that is not JSON fails with
InvalidJSONand no outputs. - A value the schema rejects fails with
ValidationFailed. The outputs are{ errors: [...] }with nobody, soctx.body('Parse')isnull. - The body is the value as read. It is never converted to the schema's types.
patternandpatternPropertiesin the schema are saved but fail the action when it runs (ActionSchemaNotSupported).- Null or empty content fails the action; whitespace reads as
null. Binary content (a file) fails.
Data operations and variables
- Select, Filter array, Join, Parse JSON, Create CSV table and Create HTML table output
{ body }: read them withctx.body('Name'). - In Compose, Response and Append values, an expression that finds nothing gives
null; the key is kept, not dropped. - Append to array variable refuses an array value (
items.push(ctx.createArray(1, 2))fails the action): an array variable holds scalars and objects. Append the items one by one.
Known differences
These are the accepted differences between a local run and the cloud:
- Parallelism. Parallel branches (
@runAfter trigger/first) run one after another locally, while the cloud runs them at the same time. Afor...ofloop without a concurrency setting also runs one item at a time locally, while the cloud's Apply to each runs up to 20 iterations at once by default. (With an explicitconcurrency.repetitionsabove 1, the engine starts that many iterations at once too.) A loop that changes a variable can therefore give a different result after deploying; the editor warns withDSL047. Set@runtimeConfig {"concurrency":{"repetitions":1}}on such loops. 1vs1.0in arrays. JavaScript cannot tell them apart, soequals(createArray(1), createArray(1.0))istruelocally andfalsein the cloud. At the top level,equals(1, 1.0)istruein both.- Very large integers. Parse JSON reads integers beyond 2^53 (such as
12345678901234567) slightly differently, because a JavaScript number cannot hold them exactly. - Error positions. An Until condition that fails reports line 1, column 0 locally; the cloud reports the position in the deployed definition.
- Cloud-only details. Action records have no billing counter, no connector host details, and no gateway or tracing headers. SharePoint thumbnails have no images, and user
Department/JobTitleare always null locally. - Debugger-friendly outputs. Variable actions record the variable's value, and blocks record iteration counts, the branch taken or the matched case, so the trace and debugger can show them. Flows read variables with
variables()andactions()/result()leave block outputs out, so this does not change what a flow sees.
The HTTP action, Dataverse, SharePoint and the other connectors have their own output and failure rules; see their pages under Connectors.