Skip to main content

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):

ValueText
true / falseTrue / False
null'' (empty)
7.07 (numbers are written as .NET writes them)
an object or arrayits 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') is false, and arrays and objects compare element by element. String comparison in equals() is case-sensitive. With more than two arguments, it is true when 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() and not() insist on booleans. if(), and(), or() and coalesce() evaluate only the arguments they need, so if(true, 'ok', int('abc')) is 'ok'.
  • There are no ge or le functions; use greaterOrEquals and lessOrEquals.

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() and endsWith() ignore case: startsWith('Hello', 'he') is true.
  • contains(), replace() and split() are case-sensitive.
  • Property names match case-insensitively: ?['name'] finds a property called Name when 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 with DSL050.
  • createArray() needs at least one argument. In the DSL, an empty array literal in an expression (x ?? []) compiles to json('[]'), which works everywhere.
  • item() outside a loop is null. 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"
}
  • status is Succeeded, Failed, Skipped, TimedOut or Cancelled.
  • code is 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 no error; read outputs.statusCode and body instead.
  • An expression that fails gives code BadRequest and an error with code InvalidTemplate.
  • A block that failed because something inside it failed has code ActionFailed.
  • A skipped action has code ActionSkipped and an error whose code says why: ActionConditionFailed (its runAfter was not met), ActionBranchingConditionNotSatisfied (it is in an if/switch branch that was not taken), or ActionDependencyFailed (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 (while and do...while in the DSL) runs its body before checking the condition, so the body always runs at least once, even for a while whose 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 if whose condition fails, ends with BadRequest / InvalidTemplate, and its body is recorded as Skipped (ActionDependencyFailed).

Branches and switches​

  • The branch of an if or switch that was not taken is recorded as Skipped, and appears in the trace.
  • A switch fails when its value is not a string or an integer. A case value of a different type than the switch value ('2' against 2) 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 NoResponse error, 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 InvalidJSON and no outputs.
  • A value the schema rejects fails with ValidationFailed. The outputs are { errors: [...] } with no body, so ctx.body('Parse') is null.
  • The body is the value as read. It is never converted to the schema's types.
  • pattern and patternProperties in 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 with ctx.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. A for...of loop 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 explicit concurrency.repetitions above 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 with DSL047. Set @runtimeConfig {"concurrency":{"repetitions":1}} on such loops.
  • 1 vs 1.0 in arrays. JavaScript cannot tell them apart, so equals(createArray(1), createArray(1.0)) is true locally and false in the cloud. At the top level, equals(1, 1.0) is true in 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/JobTitle are 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() and actions()/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.